From 96abdba37271e4f66c0d92cccf684c537fa10186 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 14:15:39 +0200 Subject: [PATCH 01/11] feat: add thread archiving and unread filters Add per-phone outgoing archiving and server-side unread filtering. Include integration coverage, frontend type fixes, and login badge fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/docs/docs.go | 18 + api/docs/swagger.json | 18 + api/docs/swagger.yaml | 16 + api/pkg/entities/phone.go | 3 + api/pkg/handlers/message_thread_handler.go | 1 + .../message_thread_handler_contacts_test.go | 32 +- .../handlers/message_thread_handler_test.go | 2 +- .../read_receipts_test_helpers_test.go | 2 +- .../gorm_message_thread_repository.go | 6 +- .../gorm_message_thread_repository_test.go | 37 + .../repositories/message_thread_repository.go | 2 +- .../requests/message_thread_index_request.go | 6 + api/pkg/requests/phone_update_request.go | 31 +- api/pkg/requests/phone_update_request_test.go | 39 + api/pkg/services/message_thread_service.go | 14 +- .../message_thread_service_contacts_test.go | 2 +- .../services/message_thread_service_test.go | 148 ++- api/pkg/services/phone_service.go | 29 +- api/pkg/services/phone_service_test.go | 35 + .../message_thread_handler_validator.go | 4 + ...tgoing_message_threads_integration_test.go | 131 +++ tests/unarchive_thread_integration_test.go | 1 + tests/unread_filter_integration_test.go | 90 ++ web/app/components/FirebaseAuth.vue | 6 +- web/app/components/MessageThread.vue | 20 +- web/app/components/MessageThreadHeader.vue | 304 +++--- web/app/composables/useApi.ts | 4 +- web/app/layouts/default.vue | 16 +- web/app/pages/bulk-messages/index.vue | 4 +- web/app/pages/index.vue | 4 +- web/app/pages/phone-api-keys/index.vue | 2 +- web/app/pages/search-messages/index.vue | 2 +- web/app/pages/settings/index.vue | 9 + web/app/plugins/vPhoneInput.client.ts | 1 - web/app/stores/app.ts | 6 +- web/app/stores/phones.ts | 2 + web/app/stores/threads.test.ts | 165 ++++ web/app/stores/threads.ts | 105 +- web/app/utils/bag.ts | 4 +- web/app/utils/errors.ts | 4 +- web/nuxt.config.ts | 2 +- web/package.json | 1 + web/pnpm-lock.yaml | 913 ++++++++++-------- web/shared/types/api.ts | 20 +- web/tsconfig.json | 14 +- 45 files changed, 1633 insertions(+), 642 deletions(-) create mode 100644 api/pkg/requests/phone_update_request_test.go create mode 100644 api/pkg/services/phone_service_test.go create mode 100644 tests/archive_new_outgoing_message_threads_integration_test.go create mode 100644 tests/unread_filter_integration_test.go create mode 100644 web/app/stores/threads.test.ts diff --git a/api/docs/docs.go b/api/docs/docs.go index eb72ac76d..67842b376 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1166,6 +1166,13 @@ const docTemplate = `{ "description": "include matching contact details", "name": "contacts", "in": "query" + }, + { + "type": "boolean", + "default": false, + "description": "only include threads with unread messages", + "name": "unread_only", + "in": "query" } ], "responses": { @@ -4204,6 +4211,7 @@ const docTemplate = `{ "entities.Phone": { "type": "object", "required": [ + "archive_new_outgoing_message_threads", "created_at", "id", "max_send_attempts", @@ -4216,6 +4224,11 @@ const docTemplate = `{ "user_id" ], "properties": { + "archive_new_outgoing_message_threads": { + "description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.", + "type": "boolean", + "example": false + }, "created_at": { "type": "string", "example": "2022-06-05T14:26:02.302718+03:00" @@ -4975,6 +4988,11 @@ const docTemplate = `{ "unarchive_thread" ], "properties": { + "archive_new_outgoing_message_threads": { + "description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.", + "type": "boolean", + "example": false + }, "fcm_token": { "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 59c5637ba..c7966921d 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1163,6 +1163,13 @@ "description": "include matching contact details", "name": "contacts", "in": "query" + }, + { + "type": "boolean", + "default": false, + "description": "only include threads with unread messages", + "name": "unread_only", + "in": "query" } ], "responses": { @@ -4201,6 +4208,7 @@ "entities.Phone": { "type": "object", "required": [ + "archive_new_outgoing_message_threads", "created_at", "id", "max_send_attempts", @@ -4213,6 +4221,11 @@ "user_id" ], "properties": { + "archive_new_outgoing_message_threads": { + "description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.", + "type": "boolean", + "example": false + }, "created_at": { "type": "string", "example": "2022-06-05T14:26:02.302718+03:00" @@ -4972,6 +4985,11 @@ "unarchive_thread" ], "properties": { + "archive_new_outgoing_message_threads": { + "description": "ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone.", + "type": "boolean", + "example": false + }, "fcm_token": { "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 6dc240216..964040158 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -408,6 +408,11 @@ definitions: type: object entities.Phone: properties: + archive_new_outgoing_message_threads: + description: ArchiveNewOutgoingMessageThreads archives new conversations started + by an outgoing message on this phone. + example: false + type: boolean created_at: example: "2022-06-05T14:26:02.302718+03:00" type: string @@ -452,6 +457,7 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: + - archive_new_outgoing_message_threads - created_at - id - max_send_attempts @@ -990,6 +996,11 @@ definitions: type: object requests.PhoneUpsert: properties: + archive_new_outgoing_message_threads: + description: ArchiveNewOutgoingMessageThreads archives new conversations started + by an outgoing message on this phone. + example: false + type: boolean fcm_token: description: FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. @@ -2510,6 +2521,11 @@ paths: in: query name: contacts type: boolean + - default: false + description: only include threads with unread messages + in: query + name: unread_only + type: boolean produces: - application/json responses: diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 36be28aeb..ca5deac14 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -30,6 +30,9 @@ type Phone struct { // UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone. UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` + // ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone. + ArchiveNewOutgoingMessageThreads bool `json:"archive_new_outgoing_message_threads" gorm:"default:false" example:"false"` + CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 72e0b543d..2a8aff3cb 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -58,6 +58,7 @@ func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares . // @Param query query string false "filter message threads containing query" // @Param limit query int false "number of messages to return" minimum(1) maximum(20) // @Param contacts query bool false "include matching contact details" +// @Param unread_only query bool false "only include threads with unread messages" default(false) // @Success 200 {object} responses.MessageThreadsResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized diff --git a/api/pkg/handlers/message_thread_handler_contacts_test.go b/api/pkg/handlers/message_thread_handler_contacts_test.go index f6681fbd7..3ebca2595 100644 --- a/api/pkg/handlers/message_thread_handler_contacts_test.go +++ b/api/pkg/handlers/message_thread_handler_contacts_test.go @@ -22,10 +22,12 @@ import ( type messageThreadHandlerIndexRepositoryStub struct { repositories.MessageThreadRepository - threads []entities.MessageThread + threads []entities.MessageThread + unreadOnly bool } -func (stub *messageThreadHandlerIndexRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { +func (stub *messageThreadHandlerIndexRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, unreadOnly bool, _ repositories.IndexParams) (*[]entities.MessageThread, error) { + stub.unreadOnly = unreadOnly threads := make([]entities.MessageThread, len(stub.threads)) copy(threads, stub.threads) return &threads, nil @@ -41,7 +43,7 @@ func (stub *messageThreadHandlerContactProviderStub) GetContactMap(context.Conte return stub.contacts, nil } -func TestMessageThreadHandlerIndex_ParsesContactsQuery(t *testing.T) { +func TestMessageThreadHandlerIndex_ParsesContactsAndUnreadQuery(t *testing.T) { contact := &entities.Contact{ID: uuid.New(), Name: "Alice", PhoneNumbers: []string{"+18005550100"}} repository := &messageThreadHandlerIndexRepositoryStub{threads: []entities.MessageThread{{Contact: "+18005550100"}}} provider := &messageThreadHandlerContactProviderStub{contacts: map[string]*entities.Contact{"+18005550100": contact}} @@ -57,19 +59,41 @@ func TestMessageThreadHandlerIndex_ParsesContactsQuery(t *testing.T) { }) handler.RegisterRoutes(app) - req := httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&contacts=true", nil) + req := httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&contacts=true&unread_only=true", nil) resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, provider.calls) + assert.True(t, repository.unreadOnly) var payload struct { Data []entities.MessageThread `json:"data"` } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) require.Len(t, payload.Data, 1) require.NotNil(t, payload.Data[0].ContactDetails) assert.Equal(t, contact.ID, payload.Data[0].ContactDetails.ID) assert.Equal(t, "Alice", payload.Data[0].ContactDetails.Name) } + +func TestMessageThreadHandlerIndexRejectsInvalidUnreadFilter(t *testing.T) { + repository := &messageThreadHandlerIndexRepositoryStub{} + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, nil) + handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service) + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&unread_only=invalid", nil)) + + require.NoError(t, err) + defer response.Body.Close() + assert.Equal(t, http.StatusUnprocessableEntity, response.StatusCode) +} diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go index a304f4ad3..a9b6ccbe4 100644 --- a/api/pkg/handlers/message_thread_handler_test.go +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -45,7 +45,7 @@ func (stub *messageThreadHandlerRepositoryStub) Load(context.Context, entities.U return nil, nil } -func (stub *messageThreadHandlerRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { +func (stub *messageThreadHandlerRepositoryStub) Index(context.Context, entities.UserID, string, bool, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { return nil, nil } diff --git a/api/pkg/listeners/read_receipts_test_helpers_test.go b/api/pkg/listeners/read_receipts_test_helpers_test.go index 60cd27e65..3227685c5 100644 --- a/api/pkg/listeners/read_receipts_test_helpers_test.go +++ b/api/pkg/listeners/read_receipts_test_helpers_test.go @@ -52,7 +52,7 @@ func (repository *listenerMessageThreadRepository) Load(context.Context, entitie return &entities.MessageThread{}, nil } -func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { +func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { threads := []entities.MessageThread{} return &threads, nil } diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index c30091b9b..9f9424bf2 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -234,7 +234,7 @@ func (repository *gormMessageThreadRepository) Load(ctx context.Context, userID } // Index message threads for an owner -func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID entities.UserID, owner string, isArchived bool, params IndexParams) (*[]entities.MessageThread, error) { +func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID entities.UserID, owner string, isArchived bool, unreadOnly bool, params IndexParams) (*[]entities.MessageThread, error) { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -249,6 +249,10 @@ func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID query.Where(repository.db.Where("is_archived = ?", isArchived).Or("is_archived IS NULL")) } + if unreadOnly { + query.Where("unread_count > ?", 0) + } + if len(params.Query) > 0 { queryPattern := "%" + params.Query + "%" query.Where( diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go index 5ca7d4a3f..faca36178 100644 --- a/api/pkg/repositories/gorm_message_thread_repository_test.go +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "database/sql/driver" "errors" + "fmt" "strings" "testing" "time" @@ -24,6 +25,42 @@ type messageThreadTestStatement struct { args []any } +func TestMessageThreadIndexFiltersUnreadBeforePagination(t *testing.T) { + for _, unreadOnly := range []bool{false, true} { + t.Run(fmt.Sprintf("unread_only=%t", unreadOnly), func(t *testing.T) { + db, err := gorm.Open(postgres.New(postgres.Config{Conn: &messageThreadTestConnPool{}}), &gorm.Config{ + DisableAutomaticPing: true, + DryRun: true, + }) + require.NoError(t, err) + var query string + var args []any + require.NoError(t, db.Callback().Query().After("gorm:query").Register("capture", func(db *gorm.DB) { + query = db.Statement.SQL.String() + args = db.Statement.Vars + })) + logger := &messageThreadTestLogger{} + repository := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db) + + _, err = repository.Index(context.Background(), entities.UserID("user-id"), "+18005550199", true, unreadOnly, IndexParams{Limit: 20, Skip: 5}) + + require.NoError(t, err) + assert.Contains(t, query, "user_id = $1") + assert.Contains(t, query, "owner = $2") + assert.Contains(t, query, "is_archived = $3") + assert.Equal(t, []any{entities.UserID("user-id"), "+18005550199", true}, args[:3]) + if unreadOnly { + assert.Contains(t, query, "unread_count >") + assert.Less(t, strings.Index(query, "unread_count >"), strings.Index(query, "LIMIT")) + } else { + assert.NotContains(t, query, "unread_count >") + } + assert.Contains(t, query, "ORDER BY order_timestamp DESC LIMIT") + assert.Contains(t, query, "OFFSET") + }) + } +} + type messageThreadTestConnPool struct { statements []messageThreadTestStatement } diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go index 28efc11ee..88e7a896d 100644 --- a/api/pkg/repositories/message_thread_repository.go +++ b/api/pkg/repositories/message_thread_repository.go @@ -53,7 +53,7 @@ type MessageThreadRepository interface { Load(ctx context.Context, userID entities.UserID, ID uuid.UUID) (*entities.MessageThread, error) // Index message threads for an owner - Index(ctx context.Context, userID entities.UserID, owner string, archived bool, params IndexParams) (*[]entities.MessageThread, error) + Index(ctx context.Context, userID entities.UserID, owner string, archived bool, unreadOnly bool, params IndexParams) (*[]entities.MessageThread, error) // UpdateAfterDeletedMessage updates a thread after the original message has been deleted UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error diff --git a/api/pkg/requests/message_thread_index_request.go b/api/pkg/requests/message_thread_index_request.go index 053f90003..7cf20f693 100644 --- a/api/pkg/requests/message_thread_index_request.go +++ b/api/pkg/requests/message_thread_index_request.go @@ -14,6 +14,7 @@ import ( type MessageThreadIndex struct { request IsArchived string `json:"is_archived" query:"is_archived" example:"false"` + UnreadOnly string `json:"unread_only" query:"unread_only" example:"false"` Skip string `json:"skip" query:"skip"` Query string `json:"query" query:"query"` Limit string `json:"limit" query:"limit"` @@ -36,6 +37,10 @@ func (input *MessageThreadIndex) Sanitize() MessageThreadIndex { } input.IsArchived = input.sanitizeBool(input.IsArchived) + input.UnreadOnly = input.sanitizeBool(input.UnreadOnly) + if input.UnreadOnly == "" { + input.UnreadOnly = "false" + } input.Contacts = input.sanitizeBool(input.Contacts) if input.Contacts != "true" && input.Contacts != "false" { input.Contacts = "false" @@ -62,6 +67,7 @@ func (input *MessageThreadIndex) ToGetParams(userID entities.UserID) services.Me }, UserID: userID, IsArchived: input.getBool(input.IsArchived), + UnreadOnly: input.getBool(input.UnreadOnly), WithContacts: input.getBool(input.Contacts), Owner: input.Owner, } diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index cedd51a0d..48f8dbff7 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -33,6 +33,9 @@ type PhoneUpsert struct { // UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone. UnarchiveThread bool `json:"unarchive_thread" example:"false"` + // ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone. + ArchiveNewOutgoingMessageThreads bool `json:"archive_new_outgoing_message_threads" example:"false" validate:"optional"` + // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` @@ -84,6 +87,11 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin unarchiveThread = &input.UnarchiveThread } + var archiveNewOutgoingMessageThreads *bool + if _, exists := fields["archive_new_outgoing_message_threads"]; exists { + archiveNewOutgoingMessageThreads = &input.ArchiveNewOutgoingMessageThreads + } + var scheduleID *uuid.UUID if _, exists := fields["message_send_schedule_id"]; exists { if parsed, err := uuid.Parse(strings.TrimSpace(input.MessageSendScheduleID)); err == nil { @@ -92,16 +100,17 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin } return &services.PhoneUpsertParams{ - Source: source, - PhoneNumber: phone, - MessagesPerMinute: messagesPerMinute, - MissedCallAutoReply: input.MissedCallAutoReply, - MessageExpirationDuration: timeout, - MaxSendAttempts: maxSendAttempts, - FcmToken: fcmToken, - UserID: user.ID, - SIM: entities.SIM(input.SIM), - UnarchiveThread: unarchiveThread, - MessageSendScheduleID: scheduleID, + Source: source, + PhoneNumber: phone, + MessagesPerMinute: messagesPerMinute, + MissedCallAutoReply: input.MissedCallAutoReply, + MessageExpirationDuration: timeout, + MaxSendAttempts: maxSendAttempts, + FcmToken: fcmToken, + UserID: user.ID, + SIM: entities.SIM(input.SIM), + UnarchiveThread: unarchiveThread, + ArchiveNewOutgoingMessageThreads: archiveNewOutgoingMessageThreads, + MessageSendScheduleID: scheduleID, } } diff --git a/api/pkg/requests/phone_update_request_test.go b/api/pkg/requests/phone_update_request_test.go new file mode 100644 index 000000000..ff5a0e0ae --- /dev/null +++ b/api/pkg/requests/phone_update_request_test.go @@ -0,0 +1,39 @@ +package requests + +import ( + "encoding/json" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPhoneUpsertArchiveNewOutgoingMessageThreadsPresence(t *testing.T) { + tests := []struct { + name string + body string + present bool + want bool + }{ + {name: "enabled", body: `{"phone_number":"+18005550199","archive_new_outgoing_message_threads":true}`, present: true, want: true}, + {name: "disabled", body: `{"phone_number":"+18005550199","archive_new_outgoing_message_threads":false}`, present: true, want: false}, + {name: "omitted by older client", body: `{"phone_number":"+18005550199"}`, present: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var input PhoneUpsert + require.NoError(t, json.Unmarshal([]byte(test.body), &input)) + + params := input.ToUpsertParams(entities.AuthContext{}, "test", []byte(test.body)) + + if !test.present { + assert.Nil(t, params.ArchiveNewOutgoingMessageThreads) + return + } + require.NotNil(t, params.ArchiveNewOutgoingMessageThreads) + assert.Equal(t, test.want, *params.ArchiveNewOutgoingMessageThreads) + }) + } +} diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index efcfadd17..283cd4415 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -209,6 +209,15 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() + isArchived := false + if params.Status != entities.MessageStatusReceived { + phone, err := service.phoneRepository.Load(ctx, params.UserID, params.Owner) + if err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone [%s] for user [%s] to resolve ArchiveNewOutgoingMessageThreads", params.Owner, params.UserID)) + } + isArchived = phone.ArchiveNewOutgoingMessageThreads + } + unreadCount := uint(0) if params.MarkAsUnread { unreadCount = 1 @@ -220,7 +229,7 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me Owner: params.Owner, Contact: params.Contact, UserID: params.UserID, - IsArchived: false, + IsArchived: isArchived, UnreadCount: unreadCount, Color: service.getColor(), LastMessageContent: ¶ms.Content, @@ -274,6 +283,7 @@ func (service *MessageThreadService) getColor() string { type MessageThreadGetParams struct { repositories.IndexParams IsArchived bool + UnreadOnly bool WithContacts bool UserID entities.UserID Owner string @@ -284,7 +294,7 @@ func (service *MessageThreadService) GetThreads(ctx context.Context, params Mess ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - threads, err := service.repository.Index(ctx, params.UserID, params.Owner, params.IsArchived, params.IndexParams) + threads, err := service.repository.Index(ctx, params.UserID, params.Owner, params.IsArchived, params.UnreadOnly, params.IndexParams) if err != nil { return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "could not fetch messages threads for params [%+#v]", params)) } diff --git a/api/pkg/services/message_thread_service_contacts_test.go b/api/pkg/services/message_thread_service_contacts_test.go index 2c021d2f7..a941a0f7b 100644 --- a/api/pkg/services/message_thread_service_contacts_test.go +++ b/api/pkg/services/message_thread_service_contacts_test.go @@ -22,7 +22,7 @@ type messageThreadContactRepositoryStub struct { calls int } -func (stub *messageThreadContactRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, _ repositories.IndexParams) (*[]entities.MessageThread, error) { +func (stub *messageThreadContactRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, _ bool, _ repositories.IndexParams) (*[]entities.MessageThread, error) { stub.calls++ if stub.err != nil { return nil, stub.err diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go index 1ef9b4bde..2f290fa71 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -15,6 +15,15 @@ import ( "gorm.io/gorm" ) +type threadPhoneRepositoryStub struct { + repositories.PhoneRepository + load func(context.Context, entities.UserID, string) (*entities.Phone, error) +} + +func (stub *threadPhoneRepositoryStub) Load(ctx context.Context, userID entities.UserID, owner string) (*entities.Phone, error) { + return stub.load(ctx, userID, owner) +} + type messageThreadRepositoryStub struct { loadByOwnerContact func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) load func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) @@ -56,7 +65,7 @@ func (stub *messageThreadRepositoryStub) Load(ctx context.Context, userID entiti return stub.load(ctx, userID, id) } -func (stub *messageThreadRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { +func (stub *messageThreadRepositoryStub) Index(context.Context, entities.UserID, string, bool, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { threads := []entities.MessageThread{} return &threads, nil } @@ -233,3 +242,140 @@ func TestShouldCheckUnarchive(t *testing.T) { assert.False(t, service.shouldCheckUnarchive(notArchived, received), "already unarchived -> no check") assert.False(t, service.shouldCheckUnarchive(notArchived, sent), "not archived + outbound -> no check") } + +func TestCreateThreadArchivesOnlyNewOutgoingConversationsForEnabledPhone(t *testing.T) { + tests := []struct { + name string + status entities.MessageStatus + owner string + wantArchived bool + }{ + {name: "outgoing pending", status: entities.MessageStatusPending, owner: "+18005550199", wantArchived: true}, + {name: "outgoing sent", status: entities.MessageStatusSent, owner: "+18005550199", wantArchived: true}, + {name: "outgoing scheduled", status: entities.MessageStatusScheduled, owner: "+18005550199", wantArchived: true}, + {name: "incoming", status: entities.MessageStatusReceived, owner: "+18005550199", wantArchived: false}, + {name: "other phone defaults off", status: entities.MessageStatusPending, owner: "+18005550198", wantArchived: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stored *entities.MessageThread + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + store: func(_ context.Context, thread *entities.MessageThread) error { + stored = thread + return nil + }, + } + service := newMessageThreadServiceForTest(repository) + service.phoneRepository = &threadPhoneRepositoryStub{ + load: func(_ context.Context, userID entities.UserID, owner string) (*entities.Phone, error) { + assert.Equal(t, entities.UserID("user-id"), userID) + assert.Equal(t, test.owner, owner) + assert.NotEqual(t, entities.MessageStatusReceived, test.status, "incoming creation must not consult the setting") + phone := &entities.Phone{} + if owner == "+18005550199" { + phone.ArchiveNewOutgoingMessageThreads = true + } + return phone, nil + }, + } + + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: test.owner, + Contact: "+18005550100", + MessageID: uuid.New(), + Status: test.status, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, test.wantArchived, stored.IsArchived) + }) + } +} + +func TestCreateOutgoingThreadDoesNotStoreWhenPhoneCannotBeLoaded(t *testing.T) { + stored := false + service := newMessageThreadServiceForTest(&messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + store: func(context.Context, *entities.MessageThread) error { + stored = true + return nil + }, + }) + phoneErr := stacktrace.NewError("phone unavailable") + service.phoneRepository = &threadPhoneRepositoryStub{ + load: func(context.Context, entities.UserID, string) (*entities.Phone, error) { + return nil, phoneErr + }, + } + + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Status: entities.MessageStatusPending, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "phone unavailable") + assert.False(t, stored) +} + +func TestArchiveNewOutgoingMessageThreadsPreservesExistingConversationBehavior(t *testing.T) { + tests := []struct { + name string + status entities.MessageStatus + archived bool + unarchive bool + wantUnarchive bool + }{ + {name: "outgoing existing inbox", status: entities.MessageStatusPending}, + {name: "outgoing existing archive", status: entities.MessageStatusPending, archived: true}, + {name: "reply stays archived", status: entities.MessageStatusReceived, archived: true}, + {name: "reply unarchives when enabled", status: entities.MessageStatusReceived, archived: true, unarchive: true, wantUnarchive: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New(), IsArchived: test.archived}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + store: func(context.Context, *entities.MessageThread) error { + t.Fatal("existing conversations must not be recreated") + return nil + }, + } + service := newMessageThreadServiceForTest(repository) + service.phoneRepository = &threadPhoneRepositoryStub{ + load: func(context.Context, entities.UserID, string) (*entities.Phone, error) { + return &entities.Phone{UnarchiveThread: test.unarchive, ArchiveNewOutgoingMessageThreads: true}, nil + }, + } + + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Status: test.status, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + assert.Equal(t, test.wantUnarchive, captured.Unarchive) + }) + } +} diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index c7078375b..8638b61be 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -94,18 +94,19 @@ func (service *PhoneService) Load(ctx context.Context, userID entities.UserID, o // PhoneUpsertParams are parameters for creating a new entities.Phone type PhoneUpsertParams struct { - PhoneNumber *phonenumbers.PhoneNumber - FcmToken *string - MessagesPerMinute *uint - MaxSendAttempts *uint - WebhookURL *string - MessageExpirationDuration *time.Duration - MissedCallAutoReply *string - UnarchiveThread *bool - SIM entities.SIM - MessageSendScheduleID *uuid.UUID - Source string - UserID entities.UserID + PhoneNumber *phonenumbers.PhoneNumber + FcmToken *string + MessagesPerMinute *uint + MaxSendAttempts *uint + WebhookURL *string + MessageExpirationDuration *time.Duration + MissedCallAutoReply *string + UnarchiveThread *bool + ArchiveNewOutgoingMessageThreads *bool + SIM entities.SIM + MessageSendScheduleID *uuid.UUID + Source string + UserID entities.UserID } // Upsert a new entities.Phone @@ -303,6 +304,10 @@ func (service *PhoneService) update(phone *entities.Phone, params *PhoneUpsertPa phone.UnarchiveThread = *params.UnarchiveThread } + if params.ArchiveNewOutgoingMessageThreads != nil { + phone.ArchiveNewOutgoingMessageThreads = *params.ArchiveNewOutgoingMessageThreads + } + phone.SIM = params.SIM phone.MessageSendScheduleID = params.MessageSendScheduleID diff --git a/api/pkg/services/phone_service_test.go b/api/pkg/services/phone_service_test.go new file mode 100644 index 000000000..83758de80 --- /dev/null +++ b/api/pkg/services/phone_service_test.go @@ -0,0 +1,35 @@ +package services + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestPhoneUpdateArchiveNewOutgoingMessageThreads(t *testing.T) { + enabled := true + disabled := false + tests := []struct { + name string + initial bool + setting *bool + want bool + }{ + {name: "enable", setting: &enabled, want: true}, + {name: "disable", initial: true, setting: &disabled, want: false}, + {name: "omitted preserves enabled", initial: true, want: true}, + {name: "omitted defaults off", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + phone := &entities.Phone{ArchiveNewOutgoingMessageThreads: test.initial} + params := PhoneUpsertParams{ArchiveNewOutgoingMessageThreads: test.setting} + + updated := (&PhoneService{}).update(phone, ¶ms) + + assert.Equal(t, test.want, updated.ArchiveNewOutgoingMessageThreads) + }) + } +} diff --git a/api/pkg/validators/message_thread_handler_validator.go b/api/pkg/validators/message_thread_handler_validator.go index d4f08002f..3c43575b3 100644 --- a/api/pkg/validators/message_thread_handler_validator.go +++ b/api/pkg/validators/message_thread_handler_validator.go @@ -48,6 +48,10 @@ func (validator *MessageThreadHandlerValidator) ValidateMessageThreadIndex(_ con "required", "in:true,false", }, + "unread_only": []string{ + "required", + "in:true,false", + }, "query": []string{ "max:100", }, diff --git a/tests/archive_new_outgoing_message_threads_integration_test.go b/tests/archive_new_outgoing_message_threads_integration_test.go new file mode 100644 index 000000000..ff4a23728 --- /dev/null +++ b/tests/archive_new_outgoing_message_threads_integration_test.go @@ -0,0 +1,131 @@ +package tests + +import ( + "context" + "net/http" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type phoneThreadSettings struct { + ArchiveNewOutgoingMessageThreads bool `json:"archive_new_outgoing_message_threads"` + UnarchiveThread bool `json:"unarchive_thread"` +} + +func updatePhoneThreadSettings(ctx context.Context, t *testing.T, phone testPhone, settings map[string]bool) phoneThreadSettings { + t.Helper() + + payload := map[string]any{ + "phone_number": phone.PhoneNumber, + "fcm_token": phone.FcmToken, + "sim": "SIM1", + } + for key, value := range settings { + payload[key] = value + } + var response struct { + Data phoneThreadSettings `json:"data"` + } + requestJSON(ctx, t, http.MethodPut, "/v1/phones", userAPIKey, payload, http.StatusOK, &response) + return response.Data +} + +func sendThreadMessage(ctx context.Context, t *testing.T, phone testPhone, contact, content string) { + t.Helper() + + _, response, err := newAPIClient().Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: content, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) +} + +func TestArchiveNewOutgoingMessageThreadsPerPhoneAndDirection(t *testing.T) { + ctx := context.Background() + enabledPhone := setupPhone(ctx, t, 60) + defaultPhone := setupPhone(ctx, t, 60) + require.NotEqual(t, enabledPhone.PhoneNumber, defaultPhone.PhoneNumber) + + existingContact := randomPhoneNumber() + receiveInbound(ctx, t, enabledPhone.PhoneAPIKey, existingContact, enabledPhone.PhoneNumber, "Existing inbox conversation", time.Now()) + require.NotNil(t, waitForThread(ctx, t, enabledPhone.PhoneNumber, existingContact, false, "Existing inbox conversation", 20*time.Second)) + + settings := updatePhoneThreadSettings(ctx, t, enabledPhone, map[string]bool{"archive_new_outgoing_message_threads": true}) + require.True(t, settings.ArchiveNewOutgoingMessageThreads) + assert.False(t, updatePhoneThreadSettings(ctx, t, defaultPhone, nil).ArchiveNewOutgoingMessageThreads) + + contact := randomPhoneNumber() + sendThreadMessage(ctx, t, enabledPhone, contact, "New outgoing archived") + archived := waitForThread(ctx, t, enabledPhone.PhoneNumber, contact, true, "New outgoing archived", 20*time.Second) + require.NotNil(t, archived) + assert.True(t, archived.IsArchived) + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, enabledPhone.PhoneNumber, false), contact)) + + sendThreadMessage(ctx, t, defaultPhone, contact, "Other phone stays in inbox") + require.NotNil(t, waitForThread(ctx, t, defaultPhone.PhoneNumber, contact, false, "Other phone stays in inbox", 20*time.Second)) + + sendThreadMessage(ctx, t, enabledPhone, existingContact, "Existing conversation stays in inbox") + require.NotNil(t, waitForThread(ctx, t, enabledPhone.PhoneNumber, existingContact, false, "Existing conversation stays in inbox", 20*time.Second)) + + inboundContact := randomPhoneNumber() + receiveInbound(ctx, t, enabledPhone.PhoneAPIKey, inboundContact, enabledPhone.PhoneNumber, "New incoming stays in inbox", time.Now()) + require.NotNil(t, waitForThread(ctx, t, enabledPhone.PhoneNumber, inboundContact, false, "New incoming stays in inbox", 20*time.Second)) +} + +func TestArchiveNewOutgoingMessageThreadsToggleAndPartialUpdates(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + + require.True(t, updatePhoneThreadSettings(ctx, t, phone, map[string]bool{"archive_new_outgoing_message_threads": true}).ArchiveNewOutgoingMessageThreads) + settings := updatePhoneThreadSettings(ctx, t, phone, map[string]bool{"unarchive_thread": true}) + assert.True(t, settings.ArchiveNewOutgoingMessageThreads, "omitting the setting must preserve it") + assert.True(t, settings.UnarchiveThread) + + archivedContact := randomPhoneNumber() + sendThreadMessage(ctx, t, phone, archivedContact, "Archived before disabling") + require.NotNil(t, waitForThread(ctx, t, phone.PhoneNumber, archivedContact, true, "Archived before disabling", 20*time.Second)) + + settings = updatePhoneThreadSettings(ctx, t, phone, map[string]bool{"archive_new_outgoing_message_threads": false}) + require.False(t, settings.ArchiveNewOutgoingMessageThreads) + assert.True(t, settings.UnarchiveThread, "the two settings must be independent") + + inboxContact := randomPhoneNumber() + sendThreadMessage(ctx, t, phone, inboxContact, "Inbox after disabling") + require.NotNil(t, waitForThread(ctx, t, phone.PhoneNumber, inboxContact, false, "Inbox after disabling", 20*time.Second)) + assert.NotNil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, true), archivedContact), "disabling must not change existing archived threads") +} + +func TestArchiveNewOutgoingMessageThreadsReplyRespectsUnarchiveSetting(t *testing.T) { + for _, test := range []struct { + name string + unarchive bool + }{ + {name: "reply remains archived", unarchive: false}, + {name: "reply returns to inbox", unarchive: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + updatePhoneThreadSettings(ctx, t, phone, map[string]bool{ + "archive_new_outgoing_message_threads": true, + "unarchive_thread": test.unarchive, + }) + contact := randomPhoneNumber() + sendThreadMessage(ctx, t, phone, contact, "Outgoing starts archived") + require.NotNil(t, waitForThread(ctx, t, phone.PhoneNumber, contact, true, "Outgoing starts archived", 20*time.Second)) + + receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, "Incoming reply", time.Now()) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, !test.unarchive, "Incoming reply", 20*time.Second) + require.NotNil(t, thread) + assert.Equal(t, !test.unarchive, thread.IsArchived) + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, test.unarchive), contact)) + }) + } +} diff --git a/tests/unarchive_thread_integration_test.go b/tests/unarchive_thread_integration_test.go index 35ee8bf56..c7fb3f55c 100644 --- a/tests/unarchive_thread_integration_test.go +++ b/tests/unarchive_thread_integration_test.go @@ -21,6 +21,7 @@ type integrationThread struct { Contact string `json:"contact"` Owner string `json:"owner"` IsArchived bool `json:"is_archived"` + UnreadCount uint `json:"unread_count"` LastMessageContent *string `json:"last_message_content"` } diff --git a/tests/unread_filter_integration_test.go b/tests/unread_filter_integration_test.go new file mode 100644 index 000000000..9545afab3 --- /dev/null +++ b/tests/unread_filter_integration_test.go @@ -0,0 +1,90 @@ +package tests + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fetchFilteredThreads(ctx context.Context, t *testing.T, owner string, archived, unreadOnly bool, limit, skip int) []integrationThread { + t.Helper() + + query := url.Values{ + "owner": {owner}, + "is_archived": {fmt.Sprint(archived)}, + "unread_only": {fmt.Sprint(unreadOnly)}, + "limit": {fmt.Sprint(limit)}, + "skip": {fmt.Sprint(skip)}, + } + var response struct { + Data []integrationThread `json:"data"` + } + requestJSON(ctx, t, http.MethodGet, "/v1/message-threads?"+query.Encode(), userAPIKey, nil, http.StatusOK, &response) + return response.Data +} + +func TestMessageThreadUnreadFilter(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + otherPhone := setupPhone(ctx, t, 60) + require.NotEqual(t, phone.PhoneNumber, otherPhone.PhoneNumber) + unreadContact := randomPhoneNumber() + readContact := randomPhoneNumber() + require.NotEqual(t, unreadContact, readContact) + + receiveInbound(ctx, t, phone.PhoneAPIKey, unreadContact, phone.PhoneNumber, "Older unread message", time.Now().Add(-2*time.Minute)) + unread := waitForThread(ctx, t, phone.PhoneNumber, unreadContact, false, "Older unread message", 20*time.Second) + require.NotNil(t, unread) + require.Equal(t, uint(1), unread.UnreadCount) + + receiveInbound(ctx, t, phone.PhoneAPIKey, readContact, phone.PhoneNumber, "Recent read message", time.Now().Add(-time.Minute)) + read := waitForThread(ctx, t, phone.PhoneNumber, readContact, false, "Recent read message", 20*time.Second) + require.NotNil(t, read) + clearMessageThreadUnreadCount(ctx, t, read.ID) + + receiveInbound(ctx, t, otherPhone.PhoneAPIKey, unreadContact, otherPhone.PhoneNumber, "Other phone unread", time.Now()) + require.NotNil(t, waitForThread(ctx, t, otherPhone.PhoneNumber, unreadContact, false, "Other phone unread", 20*time.Second)) + + all := fetchThreads(ctx, t, phone.PhoneNumber, false) + require.Len(t, all, 2, "All defaults to including read and unread conversations for this phone") + assert.Equal(t, []string{read.ID, unread.ID}, []string{all[0].ID, all[1].ID}) + + filtered := fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, true, 1, 0) + require.Len(t, filtered, 1, "the unread condition must run before the result limit") + assert.Equal(t, unread.ID, filtered[0].ID) + assert.Positive(t, filtered[0].UnreadCount) + assert.Empty(t, fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, true, 1, 1), "offset must apply to the filtered result") + + all = fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, false, 1, 0) + require.Len(t, all, 1) + assert.Equal(t, read.ID, all[0].ID, "All must restore the most recent read conversation") + + archiveThread(ctx, t, unread.ID) + assert.Empty(t, fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, true, 20, 0), "archived conversations must not appear in the inbox unread filter") + filtered = fetchFilteredThreads(ctx, t, phone.PhoneNumber, true, true, 20, 0) + require.Len(t, filtered, 1) + assert.Equal(t, unread.ID, filtered[0].ID) + assert.True(t, filtered[0].IsArchived) + + clearMessageThreadUnreadCount(ctx, t, unread.ID) + assert.Empty(t, fetchFilteredThreads(ctx, t, phone.PhoneNumber, true, true, 20, 0), "marking a conversation read removes it from Unread") + all = fetchFilteredThreads(ctx, t, phone.PhoneNumber, true, false, 20, 0) + require.Len(t, all, 1, "marking read must not delete or unarchive the conversation") + assert.Equal(t, unread.ID, all[0].ID) + assert.Zero(t, all[0].UnreadCount) + assert.True(t, all[0].IsArchived) +} + +func TestMessageThreadUnreadFilterRejectsInvalidValue(t *testing.T) { + query := url.Values{ + "owner": {randomPhoneNumber()}, + "unread_only": {"invalid"}, + } + requestJSON(context.Background(), t, http.MethodGet, "/v1/message-threads?"+query.Encode(), userAPIKey, nil, http.StatusUnprocessableEntity, nil) +} diff --git a/web/app/components/FirebaseAuth.vue b/web/app/components/FirebaseAuth.vue index 5f783e07c..659b426aa 100644 --- a/web/app/components/FirebaseAuth.vue +++ b/web/app/components/FirebaseAuth.vue @@ -300,7 +300,7 @@ function getGeneralErrorMessage( block color="white" size="large" - class="mb-3 position-relative" + class="mb-3 position-relative overflow-visible" :loading="loading" :disabled="loading" @click="signInWithGoogle" @@ -324,7 +324,7 @@ function getGeneralErrorMessage( size="large" variant="flat" color="black" - class="mb-3 position-relative" + class="mb-3 position-relative overflow-visible" :loading="loading" :disabled="loading" @click="signInWithGithub" @@ -349,7 +349,7 @@ function getGeneralErrorMessage( size="large" variant="flat" color="red" - class="mb-3 position-relative" + class="mb-3 position-relative overflow-visible" :disabled="loading" @click="showEmailForm = true" > diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 2a699408a..5ef623c95 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -94,7 +94,8 @@ function threadAvatarInitial(thread: EntitiesMessageThread): string {
+

+ No unread conversations +

diff --git a/web/app/composables/useApi.ts b/web/app/composables/useApi.ts index b69020c03..4c6e9a836 100644 --- a/web/app/composables/useApi.ts +++ b/web/app/composables/useApi.ts @@ -1,5 +1,3 @@ -import type { $Fetch } from 'ofetch' - let authToken: string | null = null let apiKey: string | null = null @@ -11,7 +9,7 @@ export function setApiKey(key: string | null) { apiKey = key } -function createApiFetch(): $Fetch { +function createApiFetch() { const config = useRuntimeConfig() const publicConfig = config.public as Record const baseURL = publicConfig.apiBaseUrl diff --git a/web/app/layouts/default.vue b/web/app/layouts/default.vue index 47c901de3..12f3dfc52 100644 --- a/web/app/layouts/default.vue +++ b/web/app/layouts/default.vue @@ -68,10 +68,12 @@ function startPoller() { @@ -96,8 +98,12 @@ function startPoller() { padding-left: 400px; } } + .message-thread-sidebar { + height: 100dvh; + } .v-navigation-drawer__message-thread { - height: calc(100vh - 120px); + flex: 1; + min-height: 0; &::-webkit-scrollbar { width: 8px; } diff --git a/web/app/pages/bulk-messages/index.vue b/web/app/pages/bulk-messages/index.vue index b25c64c07..cc3bf02dd 100644 --- a/web/app/pages/bulk-messages/index.vue +++ b/web/app/pages/bulk-messages/index.vue @@ -31,7 +31,7 @@ function parseErrors(error: unknown): ErrorMessages { const bag = new ErrorMessages() const data = toApiError(error).data?.data if (data && typeof data === 'object') { - Object.keys(data).forEach((key) => bag.addMany(key, data[key])) + Object.entries(data).forEach(([key, values]) => bag.addMany(key, values)) } return bag } @@ -42,7 +42,7 @@ function cleanName(requestId: string): string { if (requestId.startsWith('bulk-xls-')) return requestId.replace(/^bulk-xls-/, '') + '.xlsx' const newFormatMatch = requestId.match(/^bulk-[0-9A-Za-z]+-(.+)$/) - if (newFormatMatch) return newFormatMatch[1] + if (newFormatMatch?.[1]) return newFormatMatch[1] return requestId.replace(/^bulk-/, '') } diff --git a/web/app/pages/index.vue b/web/app/pages/index.vue index be30493f7..b0077847a 100644 --- a/web/app/pages/index.vue +++ b/web/app/pages/index.vue @@ -53,9 +53,7 @@ const pricing = ref(0) const pricingLabels = ['10K', '20K', '50K', '100K', '200K'] const pricingLabelsFull = ['10,000', '20,000', '50,000', '100,000', '200,000'] -const planMessages = computed(() => - pricingLabels[pricing.value].replace('K', ',000'), -) +const planMessages = computed(() => pricingLabelsFull[pricing.value]) const planMonthlyPrice = computed(() => [20, 35, 89, 175, 350][pricing.value]) const planYearlyPrice = computed( () => [200, 350, 1068, 2100, 4200][pricing.value], diff --git a/web/app/pages/phone-api-keys/index.vue b/web/app/pages/phone-api-keys/index.vue index aded9453d..5b3e4779f 100644 --- a/web/app/pages/phone-api-keys/index.vue +++ b/web/app/pages/phone-api-keys/index.vue @@ -44,7 +44,7 @@ function parseErrors(error: unknown): ErrorMessages { const bag = new ErrorMessages() const data = toApiError(error).data?.data if (data && typeof data === 'object') { - Object.keys(data).forEach((key) => bag.addMany(key, data[key])) + Object.entries(data).forEach(([key, values]) => bag.addMany(key, values)) } return bag } diff --git a/web/app/pages/search-messages/index.vue b/web/app/pages/search-messages/index.vue index 324131b7b..c77d5b7ef 100644 --- a/web/app/pages/search-messages/index.vue +++ b/web/app/pages/search-messages/index.vue @@ -152,7 +152,7 @@ function parseErrors(error: unknown): ErrorMessages { const bag = new ErrorMessages() const data = toApiError(error).data?.data if (data && typeof data === 'object') { - Object.keys(data).forEach((key) => bag.addMany(key, data[key])) + Object.entries(data).forEach(([key, values]) => bag.addMany(key, values)) } return bag } diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index f4c312035..cd608ef1a 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -1698,6 +1698,15 @@ onMounted(async () => { placeholder="We are currently closed at the moment, please send us a text message from 09:00 to 17:00" hint="Here you can configure an automated SMS message which is sent to the caller when this phone has a missed call" /> + { const polling = ref(false) const appData = computed(() => { - const publicConfig = config.public as Record + const publicConfig = config.public let url = publicConfig.appUrl || '' if (url.endsWith('/')) { url = url.substring(0, url.length - 1) @@ -29,9 +29,7 @@ export const useAppStore = defineStore('app', () => { } }) - const isLocal = computed( - () => (config.public as Record).appEnv === 'local', - ) + const isLocal = computed(() => config.public.appEnv === 'local') function setPolling(value: boolean) { polling.value = value diff --git a/web/app/stores/phones.ts b/web/app/stores/phones.ts index 276fe2928..4c6a7a597 100644 --- a/web/app/stores/phones.ts +++ b/web/app/stores/phones.ts @@ -61,6 +61,8 @@ export const usePhonesStore = defineStore('phones', () => { messages_per_minute: parseInt(phone.messages_per_minute.toString()), message_send_schedule_id: phone.message_send_schedule_id ?? null, unarchive_thread: phone.unarchive_thread ?? false, + archive_new_outgoing_message_threads: + phone.archive_new_outgoing_message_threads ?? false, }, }) notificationsStore.addNotification({ diff --git a/web/app/stores/threads.test.ts b/web/app/stores/threads.test.ts new file mode 100644 index 000000000..cc9e2a8b1 --- /dev/null +++ b/web/app/stores/threads.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createPinia, setActivePinia } from 'pinia' +import { computed, ref } from 'vue' +import type { EntitiesMessageThread } from '../../shared/types/api' +import { useThreadsStore } from './threads' + +function thread(id: string, unreadCount: number): EntitiesMessageThread { + return { + id, + unread_count: unreadCount, + owner: '+18005550199', + contact: '+18005550100', + color: 'indigo', + is_archived: false, + last_message_content: 'Hello', + last_message_id: 'message-id', + status: 'received', + created_at: '2026-10-27T10:00:00Z', + updated_at: '2026-10-27T10:00:00Z', + order_timestamp: '2026-10-27T10:00:00Z', + user_id: 'user-id', + } +} + +function setupStore() { + const calls: { path: string; params?: Record }[] = [] + let responseThreads = [thread('unread', 2), thread('read', 0)] + let nextResponse: Promise<{ data: EntitiesMessageThread[] }> | undefined + Object.assign(globalThis, { + ref, + computed, + useApi: () => ({ + apiFetch: async ( + path: string, + options: { + params?: Record + method?: string + }, + ) => { + calls.push({ path, params: options.params }) + if (options.method === 'PUT') { + return { data: thread('unread', 0) } + } + if (path === '/v1/messages') return { data: [] } + const pending = nextResponse + nextResponse = undefined + return pending ?? { data: responseThreads } + }, + }), + usePhonesStore: () => ({ + owner: '+18005550199', + phones: [], + getHeartbeat: async () => [], + }), + useNotificationsStore: () => ({ addNotification: () => {} }), + }) + setActivePinia(createPinia()) + return { + store: useThreadsStore(), + calls, + respondWith: (threads: EntitiesMessageThread[]) => { + responseThreads = threads + }, + deferNextLoad: (response: Promise<{ data: EntitiesMessageThread[] }>) => { + nextResponse = response + }, + } +} + +test('Unread requests server-side filtering within the API limit', async () => { + const { store, calls } = setupStore() + store.unreadOnly = true + store.archivedThreads = true + + await store.loadThreads() + + assert.equal(calls[0]?.params?.unread_only, true) + assert.equal(calls[0]?.params?.is_archived, true) + assert.equal(calls[0]?.params?.owner, '+18005550199') + assert.equal(calls[0]?.params?.limit, 20) +}) + +test('marking a thread read hides it from Unread without closing it', async () => { + const { store, calls, respondWith } = setupStore() + await store.loadThreads() + store.setThreadId('unread') + store.unreadOnly = true + + await store.resetThreadUnreadCount('unread') + + assert.ok(store.visibleThreads) + assert.deepEqual(store.visibleThreads, []) + assert.equal(store.currentThread?.id, 'unread') + assert.equal(store.currentThread.unread_count, 0) + + respondWith([]) + await store.loadThreads() + assert.equal(store.currentThread?.id, 'unread') + await store.loadThreadMessages('unread') + assert.equal(calls.at(-1)?.path, '/v1/messages') +}) + +test('switching back to All restores read conversations', async () => { + const { store } = setupStore() + await store.loadThreads() + store.unreadOnly = true + + assert.ok(store.visibleThreads) + assert.deepEqual( + store.visibleThreads.map((item) => item.id), + ['unread'], + ) + + store.unreadOnly = false + await store.loadThreads() + assert.deepEqual( + store.visibleThreads.map((item) => item.id), + ['unread', 'read'], + ) +}) + +test('resetting the store clears the filter and retained conversation', async () => { + const { store, respondWith } = setupStore() + await store.loadThreads() + store.setThreadId('unread') + store.unreadOnly = true + respondWith([]) + await store.loadThreads() + + store.resetState() + + assert.equal(store.unreadOnly, false) + assert.equal(store.currentThread, null) + assert.deepEqual(store.threads, []) +}) + +test('an older polling response cannot overwrite a newer unread-filter load', async () => { + const { store, respondWith, deferNextLoad } = setupStore() + const pending = Promise.withResolvers<{ data: EntitiesMessageThread[] }>() + deferNextLoad(pending.promise) + const oldLoad = store.loadThreads() + store.unreadOnly = true + respondWith([thread('unread', 2)]) + await store.loadThreads() + + pending.resolve({ data: [thread('read', 0)] }) + await oldLoad + + assert.deepEqual( + store.threads.map((item) => item.id), + ['unread'], + ) +}) + +test('All does not retain a conversation removed from the server list', async () => { + const { store, respondWith } = setupStore() + await store.loadThreads() + store.setThreadId('unread') + respondWith([]) + + await store.loadThreads() + + assert.equal(store.currentThread, null) +}) diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 1abd68bea..8cd5ab399 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -9,68 +9,99 @@ export const useThreadsStore = defineStore('threads', () => { const threadId = ref(null) const loadingThreads = ref(true) const archivedThreads = ref(false) + const unreadOnly = ref(false) + const retainedThread = ref(null) + let threadRequestId = 0 const { apiFetch } = useApi() const notificationsStore = useNotificationsStore() const currentThread = computed(() => { - return threads.value.find((x) => x.id === threadId.value) ?? null + return ( + threads.value.find((x) => x.id === threadId.value) ?? + ((unreadOnly.value || loadingThreads.value) && + retainedThread.value?.id === threadId.value + ? retainedThread.value + : null) + ) }) + const visibleThreads = computed(() => + unreadOnly.value + ? threads.value.filter((thread) => thread.unread_count > 0) + : threads.value, + ) + const hasThread = computed( () => threadId.value != null && !loadingThreads.value, ) function hasThreadId(id: string): boolean { - return threads.value.find((x) => x.id === id) !== undefined + return ( + threads.value.some((thread) => thread.id === id) || + currentThread.value?.id === id + ) } function replaceThread(updatedThread: EntitiesMessageThread) { const index = threads.value.findIndex( (thread) => thread.id === updatedThread.id, ) + const existingThread = + index !== -1 ? threads.value[index] : retainedThread.value + const replacement = { + ...updatedThread, + // Mark-read PUT responses are not contact-enriched. + contact_details: + updatedThread.contact_details ?? + (updatedThread.contact === existingThread?.contact + ? existingThread.contact_details + : undefined), + } if (index !== -1) { - const existingThread = threads.value[index]! - threads.value[index] = { - ...updatedThread, - // Mark-read PUT responses are not contact-enriched; keep the resolved - // contact so display names do not disappear after the thread updates. - contact_details: - updatedThread.contact_details ?? - (updatedThread.contact === existingThread.contact - ? existingThread.contact_details - : undefined), - } + threads.value[index] = replacement + } + if (updatedThread.id === threadId.value) { + retainedThread.value = replacement } } async function loadThreads() { + const requestId = ++threadRequestId const phonesStore = usePhonesStore() if (phonesStore.owner === null && phonesStore.phones.length === 0) { loadingThreads.value = false return } - const response = await apiFetch<{ data: EntitiesMessageThread[] }>( - '/v1/message-threads', - { - params: { - owner: phonesStore.owner ?? phonesStore.phones[0]?.phone_number, - limit: 100, - is_archived: archivedThreads.value, - contacts: true, + try { + const response = await apiFetch<{ data: EntitiesMessageThread[] }>( + '/v1/message-threads', + { + params: { + owner: phonesStore.owner ?? phonesStore.phones[0]?.phone_number, + limit: 20, + is_archived: archivedThreads.value, + unread_only: unreadOnly.value, + contacts: true, + }, }, - }, - ) - - phonesStore.getHeartbeat().catch(console.error) - threads.value = [...response.data] - loadingThreads.value = false + ) + if (requestId !== threadRequestId) return + + phonesStore.getHeartbeat().catch(console.error) + retainedThread.value = currentThread.value + threads.value = [...response.data] + } finally { + if (requestId === threadRequestId) { + loadingThreads.value = false + } + } } async function loadThreadMessages( id: string | null, ): Promise { - threadId.value = id + setThreadId(id) const thread = currentThread.value if (!thread) throw new Error(`Cannot find thread with id ${id}`) @@ -88,6 +119,11 @@ export const useThreadsStore = defineStore('threads', () => { } function setThreadId(id: string | null) { + retainedThread.value = + id === null + ? null + : (threads.value.find((thread) => thread.id === id) ?? + (retainedThread.value?.id === id ? retainedThread.value : null)) threadId.value = id } @@ -106,7 +142,7 @@ export const useThreadsStore = defineStore('threads', () => { threads.value = threads.value.filter( (thread) => thread.id !== payload.threadId, ) - threadId.value = null + setThreadId(null) notificationsStore.addNotification({ message: payload.isArchived ? 'Archived' : 'Unarchived', type: 'success', @@ -114,7 +150,9 @@ export const useThreadsStore = defineStore('threads', () => { } async function resetThreadUnreadCount(threadId: string, force = false) { - const thread = threads.value.find((item) => item.id === threadId) + const thread = + threads.value.find((item) => item.id === threadId) ?? + (retainedThread.value?.id === threadId ? retainedThread.value : null) if (!thread) throw new Error(`Cannot find thread with id ${threadId}`) if (!force && thread.unread_count === 0) return @@ -147,7 +185,7 @@ export const useThreadsStore = defineStore('threads', () => { async function deleteThread(id: string) { await apiFetch(`/v1/message-threads/${id}`, { method: 'DELETE' }) - threadId.value = null + setThreadId(null) notificationsStore.addNotification({ message: 'The message thread has been deleted successfully', type: 'success', @@ -155,9 +193,12 @@ export const useThreadsStore = defineStore('threads', () => { } function resetState() { + threadRequestId++ threads.value = [] threadId.value = null + retainedThread.value = null archivedThreads.value = false + unreadOnly.value = false loadingThreads.value = true } @@ -166,6 +207,8 @@ export const useThreadsStore = defineStore('threads', () => { threadId, loadingThreads, archivedThreads, + unreadOnly, + visibleThreads, currentThread, hasThread, hasThreadId, diff --git a/web/app/utils/bag.ts b/web/app/utils/bag.ts index 73c46dd13..ce977fbe3 100644 --- a/web/app/utils/bag.ts +++ b/web/app/utils/bag.ts @@ -11,8 +11,8 @@ export default class Bag { static fromObject(items: Record>): Bag { const result = new Bag() - Object.keys(items).forEach((key) => { - result.addMany(key, items[key]) + Object.entries(items).forEach(([key, values]) => { + result.addMany(key, values) }) return result } diff --git a/web/app/utils/errors.ts b/web/app/utils/errors.ts index b72bfbf38..38bc5f942 100644 --- a/web/app/utils/errors.ts +++ b/web/app/utils/errors.ts @@ -39,8 +39,8 @@ export const getErrorMessages = (error: AxiosLikeError): ErrorMessages => { return errors } - Object.keys(error.response.data.data).forEach((key: string) => { - errors.addMany(key, sanitize(key, error.response!.data!.data![key])) + Object.entries(error.response.data.data).forEach(([key, values]) => { + errors.addMany(key, sanitize(key, values)) }) return errors diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index c574ce308..87bcc2ace 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -20,7 +20,7 @@ export default defineNuxtConfig({ download: true, }, - css: ['vuetify/styles'], + css: ['vuetify/styles', 'v-phone-input/styles'], site: { url: process.env.APP_URL || 'https://httpsms.com', diff --git a/web/package.json b/web/package.json index 9d88845c8..994deaa06 100644 --- a/web/package.json +++ b/web/package.json @@ -62,6 +62,7 @@ "stylelint-config-standard": "^40.0.0", "tsx": "^4.23.15", "typescript": "^6.0.3", + "vue-tsc": "^3.3.13", "vuetify-nuxt-module": "^0.19.5" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 729f94881..8dac5d1fa 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 3.2.0(magicast@0.5.4) '@pinia/nuxt': specifier: ^0.11.3 - version: 0.11.3(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + version: 0.11.3(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -43,7 +43,7 @@ importers: version: 2.31.0 nuxt: specifier: ^4.5.1 - version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) + version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) @@ -70,7 +70,7 @@ importers: version: 5.3.4(chart.js@4.5.1)(vue@3.5.41(typescript@6.0.3)) vue-router: specifier: ^5.0.7 - version: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + version: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.25.12)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) vuetify: specifier: ^4.0.7 version: 4.1.8(typescript@6.0.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.41(typescript@6.0.3)) @@ -83,19 +83,19 @@ importers: version: 21.2.0 '@nuxt/eslint': specifier: ^1.16.0 - version: 1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(srvx@0.11.22)(supports-color@10.2.2)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + version: 1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(srvx@0.11.22)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) '@nuxtjs/seo': specifier: ^5.3.2 - version: 5.3.10(3b3462a6837e0c9bc3f7a989bd53696e) + version: 5.3.10(544d347ad5be336d527eb113013fd0f1) '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 eslint: specifier: ^10.5.0 - version: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + version: 10.8.0(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + version: 10.1.8(eslint@10.8.0(jiti@2.7.0)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -107,22 +107,25 @@ importers: version: 1.8.1 stylelint: specifier: ^17.15.0 - version: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) + version: 17.15.0(typescript@6.0.3) stylelint-config-recommended-vue: specifier: ^1.6.1 - version: 1.6.1(postcss-html@1.8.1)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)) + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.15.0(typescript@6.0.3)) stylelint-config-standard: specifier: ^40.0.0 - version: 40.0.0(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)) + version: 40.0.0(stylelint@17.15.0(typescript@6.0.3)) tsx: specifier: ^4.23.15 version: 4.23.15 typescript: specifier: ^6.0.3 version: 6.0.3 + vue-tsc: + specifier: ^3.3.13 + version: 3.3.13(typescript@6.0.3) vuetify-nuxt-module: specifier: ^0.19.5 - version: 0.19.5(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(supports-color@10.2.2)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + version: 0.19.5(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) packages: @@ -2384,6 +2387,15 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-macros/common@3.1.4': resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} engines: {node: '>=20.19.0'} @@ -2444,6 +2456,9 @@ packages: '@vue/devtools-shared@8.2.1': resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + '@vue/language-core@3.3.13': + resolution: {integrity: sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew==} + '@vue/reactivity@3.5.41': resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} @@ -2544,6 +2559,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4524,6 +4542,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5708,6 +5729,9 @@ packages: yaml: optional: true + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + vue-bundle-renderer@2.3.1: resolution: {integrity: sha512-7F4LNMopUw5RgYWo4zCmVUHCc6aQRC6dCKHUYkM/n+fux4AUGdL1x6m5A515WWyFysRRN7cx3hBzVqoisfRfzw==} @@ -5747,6 +5771,12 @@ packages: vite: optional: true + vue-tsc@3.3.13: + resolution: {integrity: sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + vue@3.5.41: resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} peerDependencies: @@ -5962,20 +5992,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@10.2.2)': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6011,41 +6041,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -6055,18 +6085,18 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -6094,24 +6124,24 @@ snapshots: dependencies: '@babel/types': 8.0.4 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -6121,7 +6151,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@10.2.2)': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -6129,7 +6159,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6323,17 +6353,17 @@ snapshots: dependencies: postcss-selector-parser: 7.1.5 - '@dxup/nuxt@0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': + '@dxup/nuxt@0.5.6(esbuild@0.25.12)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: '@dxup/unimport': 0.1.2 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) '@vue/compiler-dom': 3.5.41 chokidar: 5.0.0 knitwork: 1.3.0 magic-string: 1.1.0 pathe: 2.0.3 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -6552,23 +6582,23 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0))': dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@2.1.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint/compat@2.1.0(eslint@10.8.0(jiti@2.7.0))': dependencies: '@eslint/core': 1.2.1 optionalDependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) - '@eslint/config-array@0.23.5(supports-color@10.2.2)': + '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -6581,13 +6611,13 @@ snapshots: dependencies: '@eslint/core': 1.2.1 - '@eslint/config-inspector@3.2.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(srvx@0.11.22)': + '@eslint/config-inspector@3.2.0(eslint@10.8.0(jiti@2.7.0))(srvx@0.11.22)': dependencies: ansis: 4.3.1 cac: 7.0.0 chokidar: 5.0.0 devframe: 0.8.2(cac@7.0.0)(srvx@0.11.22) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) jiti: 2.7.0 tinyglobby: 0.2.17 transitivePeerDependencies: @@ -6604,9 +6634,9 @@ snapshots: mdn-data: 2.29.0 source-map-js: 1.2.1 - '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0))': optionalDependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -7119,19 +7149,19 @@ snapshots: '@kurkle/color@0.3.4': {} - '@kwsites/file-exists@1.1.1(supports-color@10.2.2)': + '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color '@kwsites/promise-deferred@1.1.1': {} - '@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)': + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 detect-libc: 2.1.2 - https-proxy-agent: 7.0.6(supports-color@10.2.2) + https-proxy-agent: 7.0.6 node-fetch: 2.7.0 nopt: 8.1.0 semver: 7.8.5 @@ -7173,7 +7203,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nuxt/cli@3.37.0(@nuxt/schema@4.5.2)(@parcel/watcher@2.6.0)(cac@7.0.0)(magicast@0.5.4)(supports-color@10.2.2)': + '@nuxt/cli@3.37.0(@nuxt/schema@4.5.2)(@parcel/watcher@2.6.0)(cac@7.0.0)(magicast@0.5.4)': dependencies: '@bomb.sh/tab': 0.0.19(cac@7.0.0)(citty@0.2.2) '@clack/prompts': 1.7.0 @@ -7181,7 +7211,7 @@ snapshots: citty: 0.2.2 confbox: 0.2.4 consola: 3.4.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 defu: 6.1.7 exsolve: 1.1.1 fuse.js: 7.5.0 @@ -7214,11 +7244,11 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@3.4.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': + '@nuxt/devtools-kit@3.4.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) execa: 8.0.1 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - magic-string - magicast @@ -7226,11 +7256,11 @@ snapshots: - rolldown - unplugin - '@nuxt/devtools-kit@4.0.0-alpha.7(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': + '@nuxt/devtools-kit@4.0.0-alpha.7(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) tinyexec: 1.3.0 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - magic-string - magicast @@ -7249,11 +7279,11 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.5 - '@nuxt/devtools@3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + '@nuxt/devtools@3.4.1(db0@0.3.4)(ioredis@5.11.1)(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': dependencies: - '@nuxt/devtools-kit': 3.4.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.4.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.4.1 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) '@vue/devtools-core': 8.2.1(vue@3.5.41(typescript@6.0.3)) '@vue/devtools-kit': 8.2.1 birpc: 4.0.0 @@ -7275,14 +7305,14 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 semver: 7.8.5 - simple-git: 3.36.0(supports-color@10.2.2) + simple-git: 3.36.0 sirv: 3.0.2 structured-clone-es: 2.0.1 tinyglobby: 0.2.17 - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) - vite-plugin-inspect: 11.4.1(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - vite-plugin-vue-tracer: 1.4.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite-plugin-inspect: 11.4.1(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + vite-plugin-vue-tracer: 1.4.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) which: 6.0.1 ws: 8.21.2 transitivePeerDependencies: @@ -7314,30 +7344,30 @@ snapshots: - utf-8-validate - vue - '@nuxt/eslint-config@1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@nuxt/eslint-config@1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@antfu/install-pkg': 2.0.1 '@clack/prompts': 1.7.0 - '@eslint/js': 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - '@nuxt/eslint-plugin': 1.17.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-config-flat-gitignore: 2.3.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint/js': 10.0.1(eslint@10.8.0(jiti@2.7.0)) + '@nuxt/eslint-plugin': 1.17.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0) + eslint-config-flat-gitignore: 2.3.0(eslint@10.8.0(jiti@2.7.0)) eslint-flat-config-utils: 3.2.0 - eslint-merge-processors: 2.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-import-lite: 0.6.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-jsdoc: 63.3.3(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-regexp: 3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-unicorn: 73.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-vue: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-merge-processors: 2.0.0(eslint@10.8.0(jiti@2.7.0)) + eslint-plugin-import-lite: 0.6.0(eslint@10.8.0(jiti@2.7.0)) + eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)) + eslint-plugin-jsdoc: 63.3.3(eslint@10.8.0(jiti@2.7.0)) + eslint-plugin-regexp: 3.1.1(eslint@10.8.0(jiti@2.7.0)) + eslint-plugin-unicorn: 73.0.0(eslint@10.8.0(jiti@2.7.0)) + eslint-plugin-vue: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0))) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0)) globals: 17.9.0 local-pkg: 1.2.1 pathe: 2.0.3 - vue-eslint-parser: 10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + vue-eslint-parser: 10.4.1(eslint@10.8.0(jiti@2.7.0)) transitivePeerDependencies: - '@typescript-eslint/utils' - '@vue/compiler-sfc' @@ -7345,31 +7375,31 @@ snapshots: - supports-color - typescript - '@nuxt/eslint-plugin@1.17.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@nuxt/eslint-plugin@1.17.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0) transitivePeerDependencies: - supports-color - typescript - '@nuxt/eslint@1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(srvx@0.11.22)(supports-color@10.2.2)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': + '@nuxt/eslint@1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(srvx@0.11.22)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: - '@eslint/config-inspector': 3.2.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(srvx@0.11.22) - '@nuxt/devtools-kit': 3.4.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - '@nuxt/eslint-config': 1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@nuxt/eslint-plugin': 1.17.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@eslint/config-inspector': 3.2.0(eslint@10.8.0(jiti@2.7.0))(srvx@0.11.22) + '@nuxt/devtools-kit': 3.4.1(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@nuxt/eslint-config': 1.17.0(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@nuxt/eslint-plugin': 1.17.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) chokidar: 5.0.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) eslint-flat-config-utils: 3.2.0 - eslint-typegen: 2.3.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-typegen: 2.3.1(eslint@10.8.0(jiti@2.7.0)) find-up: 8.0.0 get-port-please: 3.2.0 mlly: 1.8.2 pathe: 2.0.3 - unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unimport: 6.4.0(esbuild@0.25.12)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) transitivePeerDependencies: - '@farmfe/core' - '@modelcontextprotocol/client' @@ -7420,7 +7450,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))': + '@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))': dependencies: c12: 3.3.4(magicast@0.5.4) consola: 3.4.2 @@ -7440,7 +7470,7 @@ snapshots: scule: 1.3.0 tinyglobby: 0.2.17 ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) untyped: 2.0.0 verkit: 0.3.2 transitivePeerDependencies: @@ -7450,11 +7480,11 @@ snapshots: - rolldown - unplugin - '@nuxt/nitro-server@4.5.2(fe9885ce02309805246d9295eb36b44b)': + '@nuxt/nitro-server@4.5.2(b9bd6ba5516dc543dbc917b33a0d10ad)': dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@vue/shared': 3.5.41 consola: 3.4.2 defu: 6.1.7 @@ -7464,25 +7494,25 @@ snapshots: escape-string-regexp: 5.0.0 exsolve: 1.1.1 h3: 1.15.11 - impound: 1.1.6(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + impound: 1.1.6(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + nitropack: 2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(srvx@0.11.22)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) nostics: 1.2.0 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 rou3: 0.9.1 std-env: 4.2.0 ufo: 1.6.4 - unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) + unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) vue: 3.5.41(typescript@6.0.3) vue-bundle-renderer: 2.3.1 vue-devtools-stub: 0.1.0 optionalDependencies: - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7545,20 +7575,20 @@ snapshots: pkg-types: 2.3.1 std-env: 4.2.0 - '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))))': + '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) citty: 0.2.2 consola: 3.4.2 ofetch: 2.0.0-alpha.3 rc9: 3.0.1 std-env: 4.2.0 - '@nuxt/vite-builder@4.5.2(bbb59840355872275ede4355b56b34bc)': + '@nuxt/vite-builder@4.5.2(cbd7f0b9e291f24836832fe380a0c379)': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - '@vitejs/plugin-vue': 6.0.8(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) - '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@vitejs/plugin-vue': 6.0.8(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@vitejs/plugin-vue-jsx': 5.1.6(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) autoprefixer: 10.5.4(postcss@8.5.26) consola: 3.4.2 cssnano: 8.0.4(postcss@8.5.26) @@ -7572,7 +7602,7 @@ snapshots: knitwork: 1.3.0 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) nypm: 0.6.9 pathe: 2.0.3 pkg-types: 2.3.1 @@ -7582,23 +7612,19 @@ snapshots: std-env: 4.2.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) - vite-node: 6.0.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) - vite-plugin-checker: 0.14.5(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite-node: 6.0.0(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite-plugin-checker: 0.14.5(eslint@10.8.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.15.0(typescript@6.0.3))(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3)) vue: 3.5.41(typescript@6.0.3) vue-bundle-renderer: 2.3.1 optionalDependencies: - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) rolldown: 1.2.14 rollup-plugin-visualizer: 7.0.1(rolldown@1.2.14)(rollup@4.62.4) transitivePeerDependencies: - '@biomejs/biome' - - '@farmfe/core' - - '@rspack/core' - '@types/node' - '@vitejs/devtools' - - bun-types-no-globals - esbuild - eslint - less @@ -7608,7 +7634,6 @@ snapshots: - optionator - oxc-parser - oxlint - - rollup - sass - sass-embedded - stylelint @@ -7618,9 +7643,8 @@ snapshots: - terser - tsx - typescript - - unloader + - unplugin - vue-tsc - - webpack - yaml '@nuxtjs/google-fonts@3.2.0(magicast@0.5.4)': @@ -7631,15 +7655,15 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/robots@6.1.3(7d8087514e1614c3470bdbe0b86f0686)': + '@nuxtjs/robots@6.1.3(0655b6d95986fa09e450619a2471d7fb)': dependencies: '@fingerprintjs/botd': 2.0.0 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) consola: 3.4.2 defu: 6.1.7 h3: 1.15.11 - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) pathe: 2.0.3 pkg-types: 2.3.1 ufo: 1.6.4 @@ -7654,18 +7678,18 @@ snapshots: - vite - vue - '@nuxtjs/seo@5.3.10(3b3462a6837e0c9bc3f7a989bd53696e)': - dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - '@nuxtjs/robots': 6.1.3(7d8087514e1614c3470bdbe0b86f0686) - '@nuxtjs/sitemap': 8.3.3(7d8087514e1614c3470bdbe0b86f0686) - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) - nuxt-link-checker: 5.1.3(2afc54efe4333e7b21be164e8edbf89c) - nuxt-og-image: 6.7.6(c5b09079d96a60970e5bd704c57de0fb) - nuxt-schema-org: 6.2.8(5a5c97359c4cfa887a5b2273eea7c903) - nuxt-seo-utils: 8.3.3(58a2c7fe40baae257b6ed398e9cddc7f) - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + '@nuxtjs/seo@5.3.10(544d347ad5be336d527eb113013fd0f1)': + dependencies: + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxtjs/robots': 6.1.3(0655b6d95986fa09e450619a2471d7fb) + '@nuxtjs/sitemap': 8.3.3(0655b6d95986fa09e450619a2471d7fb) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) + nuxt-link-checker: 5.1.3(d5df5b90bf7ae7ca065098da8ab00f11) + nuxt-og-image: 6.7.6(1260c5194fdf2814eb31b9771c8bd346) + nuxt-schema-org: 6.2.8(765524ba5b9822e3426864f203b74fb3) + nuxt-seo-utils: 8.3.3(128dc6963dfcd1b0c746c3148ce2ad1c) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7724,13 +7748,13 @@ snapshots: - webpack - zod - '@nuxtjs/sitemap@8.3.3(7d8087514e1614c3470bdbe0b86f0686)': + '@nuxtjs/sitemap@8.3.3(0655b6d95986fa09e450619a2471d7fb)': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) consola: 3.4.2 defu: 6.1.7 - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -7850,7 +7874,7 @@ snapshots: '@parcel/watcher-wasm@2.6.0': dependencies: is-glob: 4.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 '@parcel/watcher-win32-arm64@2.6.0': optional: true @@ -7879,9 +7903,9 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true - '@pinia/nuxt@0.11.3(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))': + '@pinia/nuxt@0.11.3(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))': dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) pinia: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) transitivePeerDependencies: - magic-string @@ -7983,10 +8007,10 @@ snapshots: '@rollup/pluginutils': 5.4.0(rollup@4.62.4) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.5) + fdir: 6.5.0(picomatch@4.0.7) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: rollup: 4.62.4 @@ -8033,7 +8057,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: rollup: 4.62.4 @@ -8134,15 +8158,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))': + '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0))': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) '@typescript-eslint/types': 8.66.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 - picomatch: 4.0.5 + picomatch: 4.0.7 '@tybys/wasm-util@0.10.3': dependencies: @@ -8169,15 +8193,15 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.66.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -8185,23 +8209,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.66.0 - debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.66.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.66.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) '@typescript-eslint/types': 8.66.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -8215,13 +8239,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -8229,13 +8253,13 @@ snapshots: '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.66.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.66.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/project-service': 8.66.0(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -8244,13 +8268,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -8260,18 +8284,18 @@ snapshots: '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 - '@unhead/bundler@3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': + '@unhead/bundler@3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(unhead@3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))': dependencies: magic-string: 1.1.0 oxc-walker: 1.1.1(@oxc-project/types@0.154.0)(oxc-parser@0.142.0)(rolldown@1.2.14) - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unhead: 3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.25.12 lightningcss: 1.33.0 oxc-parser: 0.142.0 rolldown: 1.2.14 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@oxc-project/types' @@ -8280,15 +8304,15 @@ snapshots: - rollup - unloader - '@unhead/vue@3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + '@unhead/vue@3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': dependencies: - '@unhead/bundler': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@unhead/bundler': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(unhead@3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) hookable: 6.1.1 - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unhead: 3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) vue: 3.5.41(typescript@6.0.3) optionalDependencies: - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@oxc-project/types' @@ -8373,9 +8397,9 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/nft@1.10.2(rollup@4.62.4)(supports-color@10.2.2)': + '@vercel/nft@1.10.2(rollup@4.62.4)': dependencies: - '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) + '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.4.0(rollup@4.62.4) acorn: 8.18.0 acorn-import-attributes: 1.9.5(acorn@8.18.0) @@ -8385,31 +8409,43 @@ snapshots: glob: 13.0.6 graceful-fs: 4.2.11 node-gyp-build: 4.8.4 - picomatch: 4.0.5 + picomatch: 4.0.7 resolve-from: 5.0.0 transitivePeerDependencies: - encoding - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + '@vitejs/plugin-vue-jsx@5.1.6(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.1 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) vue: 3.5.41(typescript@6.0.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.8(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.8(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) vue: 3.5.41(typescript@6.0.3) + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + '@vue-macros/common@3.1.4(vue@3.5.41(typescript@6.0.3))': dependencies: '@vue/compiler-sfc': 3.5.41 @@ -8422,27 +8458,27 @@ snapshots: '@vue/babel-helper-vue-transform-on@2.0.1': {} - '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7)': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@vue/babel-helper-vue-transform-on': 2.0.1 - '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7) '@vue/shared': 3.5.41 optionalDependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/parser': 7.29.8 '@vue/compiler-sfc': 3.5.41 @@ -8516,6 +8552,16 @@ snapshots: '@vue/devtools-shared@8.2.1': {} + '@vue/language-core@3.3.13': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.7 + '@vue/reactivity@3.5.41': dependencies: '@vue/shared': 3.5.41 @@ -8596,6 +8642,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + alien-signals@3.2.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -8817,12 +8865,12 @@ snapshots: chownr@3.0.0: {} - chrome-launcher@1.2.1(supports-color@10.2.2): + chrome-launcher@1.2.1: dependencies: '@types/node': 26.1.2 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 - lighthouse-logger: 2.0.2(supports-color@10.2.2) + lighthouse-logger: 2.0.2 transitivePeerDependencies: - supports-color @@ -9037,11 +9085,9 @@ snapshots: db0@0.3.4: {} - debug@4.4.3(supports-color@10.2.2): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 decamelize@1.2.0: {} @@ -9222,14 +9268,14 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-flat-gitignore@2.3.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-config-flat-gitignore@2.3.0(eslint@10.8.0(jiti@2.7.0)): dependencies: - '@eslint/compat': 2.1.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@eslint/compat': 2.1.0(eslint@10.8.0(jiti@2.7.0)) + eslint: 10.8.0(jiti@2.7.0) - eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) eslint-flat-config-utils@3.2.0: dependencies: @@ -9243,20 +9289,20 @@ snapshots: optionalDependencies: unrs-resolver: 1.12.2 - eslint-merge-processors@2.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-merge-processors@2.0.0(eslint@10.8.0(jiti@2.7.0)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) - eslint-plugin-import-lite@0.6.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-import-lite@0.6.0(eslint@10.8.0(jiti@2.7.0)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) - eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)): dependencies: '@typescript-eslint/types': 8.66.0 comment-parser: 1.4.7 - debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) eslint-import-context: 0.1.9(unrs-resolver@1.12.2) is-glob: 4.0.3 minimatch: 10.2.6 @@ -9264,19 +9310,19 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.12.2 optionalDependencies: - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - supports-color - eslint-plugin-jsdoc@63.3.3(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-jsdoc@63.3.3(eslint@10.8.0(jiti@2.7.0)): dependencies: '@es-joy/jsdoccomment': 0.91.0 '@es-joy/resolve.exports': 1.2.0 are-docs-informative: 0.0.2 comment-parser: 1.4.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) espree: 11.2.0 esquery: 1.7.0 html-entities: 2.6.0 @@ -9288,20 +9334,20 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-regexp@3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-regexp@3.1.1(eslint@10.8.0(jiti@2.7.0)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.7 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) jsdoc-type-pratt-parser: 7.3.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-unicorn@73.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-unicorn@73.0.0(eslint@10.8.0(jiti@2.7.0)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) '@eslint/css-tree': 4.0.5 browserslist: 4.28.7 change-case: 5.4.4 @@ -9309,7 +9355,7 @@ snapshots: core-js-compat: 3.50.0 detect-indent: 7.0.2 entities: 4.5.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) find-up-simple: 1.0.1 globals: 17.9.0 indent-string: 5.0.0 @@ -9323,24 +9369,24 @@ snapshots: strip-indent: 4.1.1 yaml: 2.9.0 - eslint-plugin-vue@10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)): + eslint-plugin-vue@10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0))): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + eslint: 10.8.0(jiti@2.7.0) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.5 semver: 7.8.5 - vue-eslint-parser: 10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + vue-eslint-parser: 10.4.1(eslint@10.8.0(jiti@2.7.0)) xml-name-validator: 5.0.0 optionalDependencies: - '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.41)(eslint@10.8.0(jiti@2.7.0)): dependencies: '@vue/compiler-sfc': 3.5.41 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) eslint-scope@9.1.2: dependencies: @@ -9349,9 +9395,9 @@ snapshots: esrecurse: 4.3.0 estraverse: 5.3.0 - eslint-typegen@2.3.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-typegen@2.3.1(eslint@10.8.0(jiti@2.7.0)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) json-schema-to-typescript-lite: 15.0.0 ohash: 2.0.11 @@ -9361,11 +9407,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2): + eslint@10.8.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5(supports-color@10.2.2) + '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -9375,7 +9421,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -9510,9 +9556,9 @@ snapshots: dependencies: websocket-driver: 0.7.5 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 file-entry-cache@11.1.5: dependencies: @@ -9765,10 +9811,10 @@ snapshots: http-shutdown@1.2.2: {} - https-proxy-agent@7.0.6(supports-color@10.2.2): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -9803,10 +9849,10 @@ snapshots: import-meta-resolve@4.2.0: {} - importx@0.5.2(supports-color@10.2.2): + importx@0.5.2: dependencies: bundle-require: 5.1.0(esbuild@0.25.12) - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 esbuild: 0.25.12 jiti: 2.7.0 pathe: 2.0.3 @@ -9814,12 +9860,12 @@ snapshots: transitivePeerDependencies: - supports-color - impound@1.1.6(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + impound@1.1.6(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 es-module-lexer: 2.3.1 pathe: 2.0.3 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) unplugin-utils: 0.3.2 transitivePeerDependencies: - '@farmfe/core' @@ -9844,11 +9890,11 @@ snapshots: ini@6.0.0: {} - ioredis@5.11.1(supports-color@10.2.2): + ioredis@5.11.1: dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -10005,9 +10051,9 @@ snapshots: libphonenumber-js@1.13.10: {} - lighthouse-logger@2.0.2(supports-color@10.2.2): + lighthouse-logger@2.0.2: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 marky: 1.3.0 transitivePeerDependencies: - supports-color @@ -10236,7 +10282,7 @@ snapshots: natural-compare@1.4.0: {} - nitropack@2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + nitropack@2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(srvx@0.11.22)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.62.4) @@ -10246,7 +10292,7 @@ snapshots: '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.4) '@rollup/plugin-replace': 6.0.3(rollup@4.62.4) '@rollup/plugin-terser': 1.0.0(rollup@4.62.4) - '@vercel/nft': 1.10.2(rollup@4.62.4)(supports-color@10.2.2) + '@vercel/nft': 1.10.2(rollup@4.62.4) archiver: 7.0.1 c12: 3.3.4(magicast@0.5.4) chokidar: 5.0.0 @@ -10270,7 +10316,7 @@ snapshots: h3: 1.15.11 hookable: 5.5.3 httpxy: 0.5.5 - ioredis: 5.11.1(supports-color@10.2.2) + ioredis: 5.11.1 jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 @@ -10293,7 +10339,7 @@ snapshots: scule: 1.3.0 semver: 7.8.5 serve-placeholder: 2.0.2 - serve-static: 2.2.1(supports-color@10.2.2) + serve-static: 2.2.1 source-map: 0.7.6 std-env: 4.2.0 ufo: 1.6.4 @@ -10301,9 +10347,9 @@ snapshots: uncrypto: 0.1.3 unctx: 2.5.0 unenv: 2.0.0-rc.24 - unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) unplugin-utils: 0.3.2 - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -10386,18 +10432,18 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-link-checker@5.1.3(2afc54efe4333e7b21be164e8edbf89c): + nuxt-link-checker@5.1.3(d5df5b90bf7ae7ca065098da8ab00f11): dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) '@vueuse/core': 14.4.0(vue@3.5.41(typescript@6.0.3)) consola: 3.4.2 diff: 9.0.0 fuse.js: 7.5.0 h3: 1.15.11 magic-string: 1.1.0 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -10405,7 +10451,7 @@ snapshots: site-config-stack: 4.2.0(vue@3.5.41(typescript@6.0.3)) ufo: 1.6.4 ultrahtml: 1.7.0 - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -10435,13 +10481,13 @@ snapshots: - vue - zod - nuxt-og-image@6.7.6(c5b09079d96a60970e5bd704c57de0fb): + nuxt-og-image@6.7.6(1260c5194fdf2814eb31b9771c8bd346): dependencies: '@clack/prompts': 1.7.0 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@vue/compiler-sfc': 3.5.41 - chrome-launcher: 1.2.1(supports-color@10.2.2) + chrome-launcher: 1.2.1 consola: 3.4.2 defu: 6.1.7 devalue: 5.9.4 @@ -10451,8 +10497,8 @@ snapshots: magic-string: 1.1.0 magicast: 0.5.4 mocked-exports: 0.1.1 - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) nypm: 0.6.9 object-identity: 0.2.3 ofetch: 1.5.1 @@ -10467,10 +10513,10 @@ snapshots: tinyexec: 1.3.0 ufo: 1.6.4 ultrahtml: 1.7.0 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) optionalDependencies: - nitropack: 2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + nitropack: 2.13.4(@parcel/watcher@2.6.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(srvx@0.11.22)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) transitivePeerDependencies: - '@farmfe/core' - '@nuxt/schema' @@ -10487,17 +10533,17 @@ snapshots: - vue - webpack - nuxt-schema-org@6.2.8(5a5c97359c4cfa887a5b2273eea7c903): + nuxt-schema-org@6.2.8(765524ba5b9822e3426864f203b74fb3): dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) defu: 6.1.7 - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) pkg-types: 2.3.1 ufo: 1.6.4 optionalDependencies: - '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + unhead: 3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) transitivePeerDependencies: - '@nuxt/schema' - magic-string @@ -10509,31 +10555,31 @@ snapshots: - vite - vue - nuxt-seo-utils@8.3.3(58a2c7fe40baae257b6ed398e9cddc7f): + nuxt-seo-utils@8.3.3(128dc6963dfcd1b0c746c3148ce2ad1c): dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) citty: 0.2.2 consola: 3.4.2 defu: 6.1.7 escape-string-regexp: 5.0.0 exsolve: 1.1.1 image-size: 2.0.2 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) pathe: 2.0.3 pkg-types: 2.3.1 scule: 1.3.0 tinyglobby: 0.2.17 ufo: 1.6.4 optionalDependencies: - '@unhead/bundler': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) - esbuild: 0.28.1 + '@unhead/bundler': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(unhead@3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + esbuild: 0.25.12 lightningcss: 1.33.0 rolldown: 1.2.14 sharp: 0.35.4(@types/node@26.1.2) - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unhead: 3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) transitivePeerDependencies: - '@nuxt/schema' - '@types/node' @@ -10545,9 +10591,9 @@ snapshots: - vue - zod - nuxt-site-config-kit@4.2.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vue@3.5.41(typescript@6.0.3)): + nuxt-site-config-kit@4.2.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vue@3.5.41(typescript@6.0.3)): dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) site-config-stack: 4.2.0(vue@3.5.41(typescript@6.0.3)) std-env: 4.2.0 ufo: 1.6.4 @@ -10559,15 +10605,15 @@ snapshots: - unplugin - vue - nuxt-site-config@4.2.0(7d8087514e1614c3470bdbe0b86f0686): + nuxt-site-config@4.2.0(0655b6d95986fa09e450619a2471d7fb): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) consola: 3.4.2 defu: 6.1.7 h3: 1.15.11 - nuxt-site-config-kit: 4.2.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vue@3.5.41(typescript@6.0.3)) - nuxtseo-shared: 5.3.10(db742c74deaf2b1c7500561a6d1e46b8) + nuxt-site-config-kit: 4.2.0(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vue@3.5.41(typescript@6.0.3)) + nuxtseo-shared: 5.3.10(9475ed0abf43a57ec2edeac587f1a640) pathe: 2.0.3 pkg-types: 2.3.1 site-config-stack: 4.2.0(vue@3.5.41(typescript@6.0.3)) @@ -10584,17 +10630,17 @@ snapshots: - vite - zod - nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0): + nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0): dependencies: - '@dxup/nuxt': 0.5.6(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(@parcel/watcher@2.6.0)(cac@7.0.0)(magicast@0.5.4)(supports-color@10.2.2) - '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - '@nuxt/nitro-server': 4.5.2(fe9885ce02309805246d9295eb36b44b) + '@dxup/nuxt': 0.5.6(esbuild@0.25.12)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(@parcel/watcher@2.6.0)(cac@7.0.0)(magicast@0.5.4) + '@nuxt/devtools': 3.4.1(db0@0.3.4)(ioredis@5.11.1)(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/nitro-server': 4.5.2(b9bd6ba5516dc543dbc917b33a0d10ad) '@nuxt/schema': 4.5.2 - '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))) - '@nuxt/vite-builder': 4.5.2(bbb59840355872275ede4355b56b34bc) - '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))) + '@nuxt/vite-builder': 4.5.2(cbd7f0b9e291f24836832fe380a0c379) + '@unhead/vue': 3.3.1(@oxc-project/types@0.154.0)(esbuild@0.25.12)(lightningcss@1.33.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@vue/shared': 3.5.41 chokidar: 5.0.0 compatx: 0.2.0 @@ -10608,7 +10654,7 @@ snapshots: fnv1a-64: 0.1.2 hookable: 6.1.1 ignore: 7.0.6 - impound: 1.1.6(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + impound: 1.1.6(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 @@ -10635,17 +10681,17 @@ snapshots: ufo: 1.6.4 ultrahtml: 1.7.0 uncrypto: 0.1.3 - unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + unctx: 3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) undici: 8.11.2 - unhead: 3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unhead: 3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unimport: 6.4.0(esbuild@0.25.12)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) unrouting: 0.2.2 untyped: 2.0.0 verkit: 0.3.2 vue: 3.5.41(typescript@6.0.3) vue-component-type-helpers: 3.3.9 - vue-router: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + vue-router: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.25.12)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) optionalDependencies: '@parcel/watcher': 2.6.0 '@types/node': 26.1.2 @@ -10726,16 +10772,16 @@ snapshots: - xml2js - yaml - nuxtseo-shared@5.3.10(db742c74deaf2b1c7500561a6d1e46b8): + nuxtseo-shared@5.3.10(9475ed0abf43a57ec2edeac587f1a640): dependencies: '@clack/prompts': 1.7.0 - '@nuxt/devtools-kit': 4.0.0-alpha.7(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/devtools-kit': 4.0.0-alpha.7(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) '@nuxt/schema': 4.5.2 birpc: 4.0.0 consola: 3.4.2 defu: 6.1.7 - nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.28.1)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.154.0)(@parcel/watcher@2.6.0)(@types/node@26.1.2)(@vue/compiler-sfc@3.5.41)(cac@7.0.0)(db0@0.3.4)(esbuild@0.25.12)(eslint@10.8.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(meow@14.1.0)(optionator@0.9.4)(oxc-parser@0.142.0)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4))(rollup@4.62.4)(sass@1.105.1)(srvx@0.11.22)(stylelint@17.15.0(typescript@6.0.3))(terser@5.49.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3))(yaml@2.9.0) nypm: 0.6.9 ofetch: 1.5.1 pathe: 2.0.3 @@ -10746,7 +10792,7 @@ snapshots: ufo: 1.6.4 vue: 3.5.41(typescript@6.0.3) optionalDependencies: - nuxt-site-config: 4.2.0(7d8087514e1614c3470bdbe0b86f0686) + nuxt-site-config: 4.2.0(0655b6d95986fa09e450619a2471d7fb) transitivePeerDependencies: - magic-string - magicast @@ -10891,6 +10937,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-expression-matcher@1.6.2: {} @@ -11300,7 +11348,7 @@ snapshots: rollup-plugin-visualizer@7.0.1(rolldown@1.2.14)(rollup@4.62.4): dependencies: open: 11.0.0 - picomatch: 4.0.5 + picomatch: 4.0.7 source-map: 0.7.6 yargs: 18.1.0 optionalDependencies: @@ -11373,9 +11421,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1(supports-color@10.2.2): + send@1.2.1: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -11397,12 +11445,12 @@ snapshots: dependencies: defu: 6.1.7 - serve-static@2.2.1(supports-color@10.2.2): + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@10.2.2) + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -11456,13 +11504,13 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.36.0(supports-color@10.2.2): + simple-git@3.36.0: dependencies: - '@kwsites/file-exists': 1.1.1(supports-color@10.2.2) + '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 '@simple-git/args-pathspec': 1.0.3 '@simple-git/argv-parser': 1.1.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -11599,29 +11647,29 @@ snapshots: postcss: 8.5.26 postcss-selector-parser: 7.1.5 - stylelint-config-html@2.0.0(postcss-html@1.8.1)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)): + stylelint-config-html@2.0.0(postcss-html@1.8.1)(stylelint@17.15.0(typescript@6.0.3)): dependencies: postcss-html: 1.8.1 - stylelint: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) + stylelint: 17.15.0(typescript@6.0.3) - stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)): + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.15.0(typescript@6.0.3)): dependencies: postcss-html: 1.8.1 semver: 7.8.5 - stylelint: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) - stylelint-config-html: 2.0.0(postcss-html@1.8.1)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)) - stylelint-config-recommended: 18.0.0(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)) + stylelint: 17.15.0(typescript@6.0.3) + stylelint-config-html: 2.0.0(postcss-html@1.8.1)(stylelint@17.15.0(typescript@6.0.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.15.0(typescript@6.0.3)) - stylelint-config-recommended@18.0.0(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)): + stylelint-config-recommended@18.0.0(stylelint@17.15.0(typescript@6.0.3)): dependencies: - stylelint: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) + stylelint: 17.15.0(typescript@6.0.3) - stylelint-config-standard@40.0.0(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)): + stylelint-config-standard@40.0.0(stylelint@17.15.0(typescript@6.0.3)): dependencies: - stylelint: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) - stylelint-config-recommended: 18.0.0(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3)) + stylelint: 17.15.0(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.15.0(typescript@6.0.3)) - stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3): + stylelint@17.15.0(typescript@6.0.3): dependencies: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) @@ -11634,7 +11682,7 @@ snapshots: cosmiconfig: 9.0.2(typescript@6.0.3) css-functions-list: 3.3.3 css-tree: 3.2.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.5 @@ -11754,8 +11802,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 to-regex-range@5.0.1: dependencies: @@ -11808,11 +11856,11 @@ snapshots: ultrahtml@1.7.0: {} - unconfig@0.6.1(supports-color@10.2.2): + unconfig@0.6.1: dependencies: '@antfu/utils': 8.1.1 defu: 6.1.7 - importx: 0.5.2(supports-color@10.2.2) + importx: 0.5.2 transitivePeerDependencies: - supports-color @@ -11825,12 +11873,12 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 - unctx@3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))): + unctx@3.0.0(magic-string@1.1.0)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))): optionalDependencies: magic-string: 1.1.0 oxc-parser: 0.142.0 rolldown: 1.2.14 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) undici-types@8.3.0: {} @@ -11840,12 +11888,12 @@ snapshots: dependencies: pathe: 2.0.3 - unhead@3.3.1(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + unhead@3.3.1(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: hookable: 6.1.1 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) optionalDependencies: - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -11860,7 +11908,7 @@ snapshots: unicorn-magic@0.4.0: {} - unimport@6.4.0(esbuild@0.28.1)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + unimport@6.4.0(esbuild@0.25.12)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: acorn: 8.18.0 escape-string-regexp: 5.0.0 @@ -11874,7 +11922,36 @@ snapshots: scule: 1.3.0 strip-literal: 4.0.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin-utils: 0.3.2 + optionalDependencies: + oxc-parser: 0.142.0 + rolldown: 1.2.14 + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rollup + - unloader + - vite + - webpack + + unimport@6.4.0(esbuild@0.28.1)(oxc-parser@0.142.0)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + dependencies: + acorn: 8.18.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 1.1.0 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.5 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 4.0.0 + tinyglobby: 0.2.17 + unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) unplugin-utils: 0.3.2 optionalDependencies: oxc-parser: 0.142.0 @@ -11898,19 +11975,30 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.18.0 - picomatch: 4.0.5 + picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.25.12 + rolldown: 1.2.14 + rollup: 4.62.4 + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + + unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 optionalDependencies: esbuild: 0.28.1 rolldown: 1.2.14 rollup: 4.62.4 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) unrouting@0.2.2: dependencies: @@ -11944,7 +12032,7 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)): + unstorage@1.17.5(db0@0.3.4)(ioredis@5.11.1): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -11956,7 +12044,7 @@ snapshots: ufo: 1.6.4 optionalDependencies: db0: 0.3.4 - ioredis: 5.11.1(supports-color@10.2.2) + ioredis: 5.11.1 untun@0.2.2: {} @@ -12018,23 +12106,23 @@ snapshots: verkit@0.3.2: {} - vite-dev-rpc@2.0.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + vite-dev-rpc@2.0.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: birpc: 4.0.0 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) - vite-node@6.0.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0): + vite-node@6.0.0(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0): dependencies: cac: 7.0.0 es-module-lexer: 2.3.1 obug: 2.1.4 pathe: 2.0.3 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -12049,24 +12137,25 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.14.5(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.15.0(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + vite-plugin-checker@0.14.5(eslint@10.8.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.15.0(typescript@6.0.3))(typescript@6.0.3)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.13(typescript@6.0.3)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 npm-run-path: 6.0.0 picocolors: 1.1.1 - picomatch: 4.0.5 + picomatch: 4.0.7 proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) optionalDependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.0(jiti@2.7.0) meow: 14.1.0 optionator: 0.9.4 - stylelint: 17.15.0(supports-color@10.2.2)(typescript@6.0.3) + stylelint: 17.15.0(typescript@6.0.3) typescript: 6.0.3 + vue-tsc: 3.3.13(typescript@6.0.3) - vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): + vite-plugin-inspect@11.4.1(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)): dependencies: ansis: 4.3.1 error-stack-parser-es: 1.0.5 @@ -12076,33 +12165,33 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.2 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) - vite-dev-rpc: 2.0.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) optionalDependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) - vite-plugin-vue-tracer@1.4.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)): + vite-plugin-vue-tracer@1.4.0(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.1.1 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) vue: 3.5.41(typescript@6.0.3) - vite-plugin-vuetify@2.1.3(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8): + vite-plugin-vuetify@2.1.3(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8): dependencies: '@vuetify/loader-shared': 2.1.2(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 upath: 2.0.1 - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) vue: 3.5.41(typescript@6.0.3) vuetify: 4.1.8(typescript@6.0.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.41(typescript@6.0.3)) transitivePeerDependencies: - supports-color - vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0): + vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -12111,7 +12200,7 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.2 - esbuild: 0.28.1 + esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 sass: 1.105.1 @@ -12119,6 +12208,8 @@ snapshots: tsx: 4.23.15 yaml: 2.9.0 + vscode-uri@3.2.0: {} + vue-bundle-renderer@2.3.1: dependencies: ufo: 1.6.4 @@ -12132,10 +12223,10 @@ snapshots: vue-devtools-stub@0.1.0: {} - vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)): dependencies: - debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 @@ -12144,7 +12235,7 @@ snapshots: transitivePeerDependencies: - supports-color - vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)): + vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.25.12)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)): dependencies: '@babel/generator': 8.0.0 '@vue-macros/common': 3.1.4(vue@3.5.41(typescript@6.0.3)) @@ -12161,14 +12252,14 @@ snapshots: picomatch: 4.0.5 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)) unplugin-utils: 0.3.2 vue: 3.5.41(typescript@6.0.3) yaml: 2.9.0 optionalDependencies: '@vue/compiler-sfc': 3.5.41 pinia: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - vite: 8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) + vite: 8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -12179,6 +12270,12 @@ snapshots: - unloader - webpack + vue-tsc@3.3.13(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.13 + typescript: 6.0.3 + vue@3.5.41(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.41 @@ -12189,9 +12286,9 @@ snapshots: optionalDependencies: typescript: 6.0.3 - vuetify-nuxt-module@0.19.5(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(supports-color@10.2.2)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)): + vuetify-nuxt-module@0.19.5(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(typescript@6.0.3)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0)))(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)): dependencies: - '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) + '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.142.0)(rolldown@1.2.14)(unplugin@3.3.0(esbuild@0.25.12)(rolldown@1.2.14)(rollup@4.62.4)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))) defu: 6.1.7 destr: 2.0.5 local-pkg: 1.2.1 @@ -12199,9 +12296,9 @@ snapshots: perfect-debounce: 1.0.0 semver: 7.8.5 ufo: 1.6.4 - unconfig: 0.6.1(supports-color@10.2.2) + unconfig: 0.6.1 upath: 2.0.1 - vite-plugin-vuetify: 2.1.3(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) + vite-plugin-vuetify: 2.1.3(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) vuetify: 4.1.8(typescript@6.0.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.41(typescript@6.0.3)) transitivePeerDependencies: - magic-string @@ -12220,7 +12317,7 @@ snapshots: vue: 3.5.41(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 - vite-plugin-vuetify: 2.1.3(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) + vite-plugin-vuetify: 2.1.3(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) vuetify@4.2.2(typescript@6.0.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.41(typescript@6.0.3)): dependencies: @@ -12228,7 +12325,7 @@ snapshots: vue: 3.5.41(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 - vite-plugin-vuetify: 2.1.3(supports-color@10.2.2)(vite@8.3.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) + vite-plugin-vuetify: 2.1.3(vite@8.3.4(@types/node@26.1.2)(esbuild@0.25.12)(jiti@2.7.0)(sass@1.105.1)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(vuetify@4.1.8) transitivePeerDependencies: - '@adobe/leonardo-contrast-colors' - '@ant-design/colors' diff --git a/web/shared/types/api.ts b/web/shared/types/api.ts index becb20636..9ecb82efb 100644 --- a/web/shared/types/api.ts +++ b/web/shared/types/api.ts @@ -244,6 +244,11 @@ export interface EntitiesMessageThread { } export interface EntitiesPhone { + /** + * ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone. + * @example false + */ + archive_new_outgoing_message_threads: boolean; /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string; /** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." */ @@ -537,7 +542,10 @@ export interface RequestsPhoneAPIKeyStoreRequest { } export interface RequestsPhoneFCMToken { - /** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." */ + /** + * FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. + * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + */ fcm_token: string; /** @example "[+18005550199]" */ phone_number: string; @@ -549,7 +557,15 @@ export interface RequestsPhoneFCMToken { } export interface RequestsPhoneUpsert { - /** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." */ + /** + * ArchiveNewOutgoingMessageThreads archives new conversations started by an outgoing message on this phone. + * @example false + */ + archive_new_outgoing_message_threads?: boolean; + /** + * FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. + * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + */ fcm_token: string; /** * MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline. diff --git a/web/tsconfig.json b/web/tsconfig.json index 875ee5af5..307b2134b 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -14,17 +14,5 @@ { "path": "./.nuxt/tsconfig.node.json" } - ], - "compilerOptions": { - "strict": true, - "moduleResolution": "bundler", - "module": "ESNext", - "target": "ESNext", - "paths": { - "~/*": ["./app/*"], - "~~/*": ["./*"], - "#imports": ["./app/types/nuxt-shims"] - } - }, - "include": ["app/**/*.ts", "app/**/*.d.ts", "shared/**/*.ts"] + ] } From 35afdf09561781c91c76d1ec2ee900cc911705e3 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 14:43:03 +0200 Subject: [PATCH 02/11] fix(api): honor archive setting when registering phones Persist the supplied outgoing-thread archive setting on first registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/pkg/services/phone_service.go | 25 ++++---- api/pkg/services/phone_service_test.go | 59 +++++++++++++++++++ ...tgoing_message_threads_integration_test.go | 24 ++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index 8638b61be..46c984c82 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -126,7 +126,7 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara FcmToken: params.FcmToken, SIM: params.SIM, MessageSendScheduleID: params.MessageSendScheduleID, - }) + }, params.ArchiveNewOutgoingMessageThreads != nil && *params.ArchiveNewOutgoingMessageThreads) } if err != nil { @@ -225,7 +225,7 @@ func (service *PhoneService) UpsertFCMToken(ctx context.Context, params *PhoneFC phone, err := service.repository.Load(ctx, params.UserID, phonenumbers.Format(params.PhoneNumber, phonenumbers.E164)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { - return service.createPhone(ctx, params) + return service.createPhone(ctx, params, false) } if err != nil { @@ -243,7 +243,7 @@ func (service *PhoneService) UpsertFCMToken(ctx context.Context, params *PhoneFC return phone, service.dispatchPhoneUpdatedEvent(ctx, phone, params) } -func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTokenParams) (*entities.Phone, error) { +func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTokenParams, archiveNewOutgoingMessageThreads bool) (*entities.Phone, error) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -253,15 +253,16 @@ func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTo FcmToken: params.FcmToken, // Android has a limit of 30 SMS messages per minute without user permission, to be safe let's use 10 messages per minute // https://android.googlesource.com/platform/frameworks/opt/telephony/+/master/src/java/com/android/internal/telephony/SmsUsageMonitor.java#80 - MessagesPerMinute: 10, - MessageExpirationSeconds: 10 * 60, // 10 minutes - MaxSendAttempts: 2, - SIM: params.SIM, - MissedCallAutoReply: nil, - MessageSendScheduleID: params.MessageSendScheduleID, - PhoneNumber: phonenumbers.Format(params.PhoneNumber, phonenumbers.E164), - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + MessagesPerMinute: 10, + MessageExpirationSeconds: 10 * 60, // 10 minutes + MaxSendAttempts: 2, + SIM: params.SIM, + MissedCallAutoReply: nil, + MessageSendScheduleID: params.MessageSendScheduleID, + ArchiveNewOutgoingMessageThreads: archiveNewOutgoingMessageThreads, + PhoneNumber: phonenumbers.Format(params.PhoneNumber, phonenumbers.E164), + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), } if err := service.repository.Save(ctx, phone); err != nil { diff --git a/api/pkg/services/phone_service_test.go b/api/pkg/services/phone_service_test.go index 83758de80..d99cc7853 100644 --- a/api/pkg/services/phone_service_test.go +++ b/api/pkg/services/phone_service_test.go @@ -1,12 +1,71 @@ package services import ( + "context" "testing" "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/nyaruka/phonenumbers" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" ) +type phoneCreationRepository struct { + repositories.PhoneRepository + saved entities.Phone +} + +func (*phoneCreationRepository) Load(context.Context, entities.UserID, string) (*entities.Phone, error) { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "phone not found") +} + +func (repository *phoneCreationRepository) Save(_ context.Context, phone *entities.Phone) error { + repository.saved = *phone + return nil +} + +func TestPhoneUpsertAppliesArchiveSettingOnFirstRegistration(t *testing.T) { + enabled := true + disabled := false + for _, test := range []struct { + name string + setting *bool + want bool + }{ + {name: "enabled", setting: &enabled, want: true}, + {name: "disabled", setting: &disabled}, + {name: "omitted defaults off"}, + } { + t.Run(test.name, func(t *testing.T) { + repository := &phoneCreationRepository{} + logger := &noopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + dispatcher := NewEventDispatcher(logger, tracer, nil, &phoneNotificationEventQueue{}, PushQueueConfig{}) + service := NewPhoneService(logger, tracer, repository, dispatcher) + number, err := phonenumbers.Parse("+18005550199", phonenumbers.UNKNOWN_REGION) + require.NoError(t, err) + + phone, err := service.Upsert(context.Background(), &PhoneUpsertParams{ + Source: "https://httpsms.test", + UserID: entities.UserID("user-id"), + PhoneNumber: number, + SIM: entities.SIM1, + ArchiveNewOutgoingMessageThreads: test.setting, + }) + + require.NoError(t, err) + require.NotNil(t, phone) + assert.Equal(t, test.want, phone.ArchiveNewOutgoingMessageThreads) + assert.Equal(t, test.want, repository.saved.ArchiveNewOutgoingMessageThreads) + assert.Equal(t, "+18005550199", repository.saved.PhoneNumber) + }) + } +} + func TestPhoneUpdateArchiveNewOutgoingMessageThreads(t *testing.T) { enabled := true disabled := false diff --git a/tests/archive_new_outgoing_message_threads_integration_test.go b/tests/archive_new_outgoing_message_threads_integration_test.go index ff4a23728..850ee491e 100644 --- a/tests/archive_new_outgoing_message_threads_integration_test.go +++ b/tests/archive_new_outgoing_message_threads_integration_test.go @@ -7,6 +7,7 @@ import ( "time" httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,6 +47,29 @@ func sendThreadMessage(ctx context.Context, t *testing.T, phone testPhone, conta require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) } +func TestArchiveNewOutgoingMessageThreadsOnFirstRegistration(t *testing.T) { + ctx := context.Background() + phone := testPhone{ + PhoneNumber: randomPhoneNumber(), + FcmToken: "fcm-" + uuid.NewString(), + } + var response struct { + Data phoneThreadSettings `json:"data"` + } + requestJSON(ctx, t, http.MethodPut, "/v1/phones", userAPIKey, map[string]any{ + "phone_number": phone.PhoneNumber, + "fcm_token": phone.FcmToken, + "sim": "SIM1", + "archive_new_outgoing_message_threads": true, + }, http.StatusOK, &response) + require.True(t, response.Data.ArchiveNewOutgoingMessageThreads) + + contact := "+18005540100" + sendThreadMessage(ctx, t, phone, contact, "First registration archives outgoing") + require.NotNil(t, waitForThread(ctx, t, phone.PhoneNumber, contact, true, "First registration archives outgoing", 20*time.Second)) + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, false), contact)) +} + func TestArchiveNewOutgoingMessageThreadsPerPhoneAndDirection(t *testing.T) { ctx := context.Background() enabledPhone := setupPhone(ctx, t, 60) From 71df7eee3161e2bfaebbd5f208adcbd8f753a1f5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 14:43:08 +0200 Subject: [PATCH 03/11] fix(web): refresh contacts on retained conversations Apply saved contact metadata when Unread excludes the open conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- web/app/components/ContactDialog.vue | 22 +++++----- web/app/pages/threads/[id]/index.vue | 6 +-- web/app/stores/contacts.ts | 21 ++++++--- web/app/stores/threads.test.ts | 64 +++++++++++++++++++++++++++- web/app/stores/threads.ts | 17 +++++++- 5 files changed, 107 insertions(+), 23 deletions(-) diff --git a/web/app/components/ContactDialog.vue b/web/app/components/ContactDialog.vue index 2df329a93..65e559521 100644 --- a/web/app/components/ContactDialog.vue +++ b/web/app/components/ContactDialog.vue @@ -45,7 +45,7 @@ const props = withDefaults( ) const emit = defineEmits<{ - saved: [] + saved: [contacts: EntitiesContact[]] 'update:modelValue': [value: boolean] }>() @@ -183,17 +183,17 @@ async function submitForm() { const payload = buildPayload() saving.value = true try { - if (props.contact) { - await contactsStore.updateContact(props.contact.id, payload, { - refresh: props.refreshContacts, - }) - } else { - await contactsStore.saveContacts([payload], { - refresh: props.refreshContacts, - }) - } + const savedContacts = props.contact + ? [ + await contactsStore.updateContact(props.contact.id, payload, { + refresh: props.refreshContacts, + }), + ] + : await contactsStore.saveContacts([payload], { + refresh: props.refreshContacts, + }) closeDialog() - emit('saved') + emit('saved', savedContacts) } catch (error: unknown) { const bag = new ErrorMessages() const messages = fieldErrorsFromApi(error) diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 84f55ef18..720e6345d 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -21,7 +21,7 @@ import Pusher from 'pusher-js' import type { Channel } from 'pusher-js' import { isValidPhoneNumber } from 'libphonenumber-js' import { storeToRefs } from 'pinia' -import type { EntitiesMessage } from '~~/shared/types/api' +import type { EntitiesContact, EntitiesMessage } from '~~/shared/types/api' import { isUrlNotificationToken, formatWhatsappText, @@ -128,8 +128,8 @@ function currentThreadContactTitle(): string { ) } -async function refreshThreadContact() { - await threadsStore.loadThreads() +async function refreshThreadContact(contacts: EntitiesContact[]) { + await threadsStore.loadThreads(contacts) } function scrollToElement() { diff --git a/web/app/stores/contacts.ts b/web/app/stores/contacts.ts index afa798f72..ce149410f 100644 --- a/web/app/stores/contacts.ts +++ b/web/app/stores/contacts.ts @@ -117,14 +117,15 @@ export const useContactsStore = defineStore('contacts', () => { async function saveContacts( items: ContactInput | ContactInput[], options: ContactMutationOptions = {}, - ): Promise { + ): Promise { const contactsToSave = Array.isArray(items) ? items : [items] const { refresh = true } = options loading.value = true try { + let response: { data: EntitiesContact[] } try { - await apiFetch('/v1/contacts', { + response = await apiFetch<{ data: EntitiesContact[] }>('/v1/contacts', { method: 'POST', body: contactsToSave, }) @@ -146,6 +147,7 @@ export const useContactsStore = defineStore('contacts', () => { if (refresh) { await loadContacts(true) } + return response.data } finally { loading.value = false } @@ -155,15 +157,19 @@ export const useContactsStore = defineStore('contacts', () => { id: string, payload: ContactInput, options: ContactMutationOptions = {}, - ): Promise { + ): Promise { const { refresh = true } = options loading.value = true try { + let response: { data: EntitiesContact } try { - await apiFetch(`/v1/contacts/${id}`, { - method: 'PUT', - body: payload, - }) + response = await apiFetch<{ data: EntitiesContact }>( + `/v1/contacts/${id}`, + { + method: 'PUT', + body: payload, + }, + ) } catch (error: unknown) { notificationsStore.addNotification({ message: getApiErrorMessage(error, 'Error while updating contact'), @@ -179,6 +185,7 @@ export const useContactsStore = defineStore('contacts', () => { if (refresh) { await loadContacts(true) } + return response.data } finally { loading.value = false } diff --git a/web/app/stores/threads.test.ts b/web/app/stores/threads.test.ts index cc9e2a8b1..d460c6884 100644 --- a/web/app/stores/threads.test.ts +++ b/web/app/stores/threads.test.ts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { createPinia, setActivePinia } from 'pinia' import { computed, ref } from 'vue' -import type { EntitiesMessageThread } from '../../shared/types/api' +import type { + EntitiesContact, + EntitiesMessageThread, +} from '../../shared/types/api' import { useThreadsStore } from './threads' function thread(id: string, unreadCount: number): EntitiesMessageThread { @@ -68,6 +71,22 @@ function setupStore() { } } +function contact( + name: string, + phoneNumbers = ['+18005550100'], +): EntitiesContact { + return { + id: 'contact-id', + name, + phone_numbers: phoneNumbers, + emails: [], + properties: {}, + user_id: 'user-id', + created_at: '2026-10-27T10:00:00Z', + updated_at: '2026-10-27T11:00:00Z', + } +} + test('Unread requests server-side filtering within the API limit', async () => { const { store, calls } = setupStore() store.unreadOnly = true @@ -163,3 +182,46 @@ test('All does not retain a conversation removed from the server list', async () assert.equal(store.currentThread, null) }) + +for (const scenario of [ + { + name: 'adding a contact refreshes a retained read conversation', + existing: undefined, + saved: contact('New contact'), + expectedName: 'New contact', + }, + { + name: 'editing a contact refreshes a retained read conversation', + existing: contact('Old name'), + saved: contact('Updated name'), + expectedName: 'Updated name', + }, + { + name: 'removing the conversation number clears its retained contact', + existing: contact('Old name'), + saved: contact('Updated name', ['+18005550101']), + expectedName: undefined, + }, +]) { + test(scenario.name, async () => { + const { store, respondWith } = setupStore() + respondWith([ + { ...thread('unread', 1), contact_details: scenario.existing }, + ]) + await store.loadThreads() + store.setThreadId('unread') + store.unreadOnly = true + await store.resetThreadUnreadCount('unread') + respondWith([]) + await store.loadThreads() + + await store.loadThreads([scenario.saved]) + + assert.equal(store.currentThread?.id, 'unread') + assert.equal( + store.currentThread.contact_details?.name, + scenario.expectedName, + ) + assert.deepEqual(store.visibleThreads, []) + }) +} diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 8cd5ab399..9e56b106e 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import type { + EntitiesContact, EntitiesMessageThread, EntitiesMessage, } from '~~/shared/types/api' @@ -65,8 +66,22 @@ export const useThreadsStore = defineStore('threads', () => { } } - async function loadThreads() { + async function loadThreads(savedContacts: EntitiesContact[] = []) { const requestId = ++threadRequestId + const selectedThread = currentThread.value + if (selectedThread) { + for (const contact of savedContacts) { + const matchesNumber = contact.phone_numbers.includes( + selectedThread.contact, + ) + if ( + matchesNumber || + selectedThread.contact_details?.id === contact.id + ) { + selectedThread.contact_details = matchesNumber ? contact : undefined + } + } + } const phonesStore = usePhonesStore() if (phonesStore.owner === null && phonesStore.phones.length === 0) { loadingThreads.value = false From 67b05de0b8f74d572b5ef58f9dbdf3631a7cf148 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 15:01:29 +0200 Subject: [PATCH 04/11] fix(web): preserve unread polling and navigation state Refresh polls overlapping read mutations and retain scoped conversation history. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- web/app/stores/threads.test.ts | 120 +++++++++++++++++++++++++++++++-- web/app/stores/threads.ts | 52 ++++++++++++-- 2 files changed, 160 insertions(+), 12 deletions(-) diff --git a/web/app/stores/threads.test.ts b/web/app/stores/threads.test.ts index d460c6884..f4c81d3ac 100644 --- a/web/app/stores/threads.test.ts +++ b/web/app/stores/threads.test.ts @@ -30,6 +30,11 @@ function setupStore() { const calls: { path: string; params?: Record }[] = [] let responseThreads = [thread('unread', 2), thread('read', 0)] let nextResponse: Promise<{ data: EntitiesMessageThread[] }> | undefined + const phones = { + owner: '+18005550199', + phones: [], + getHeartbeat: async () => [], + } Object.assign(globalThis, { ref, computed, @@ -43,6 +48,9 @@ function setupStore() { ) => { calls.push({ path, params: options.params }) if (options.method === 'PUT') { + responseThreads = responseThreads.map((item) => + item.id === 'unread' ? { ...item, unread_count: 0 } : item, + ) return { data: thread('unread', 0) } } if (path === '/v1/messages') return { data: [] } @@ -51,16 +59,13 @@ function setupStore() { return pending ?? { data: responseThreads } }, }), - usePhonesStore: () => ({ - owner: '+18005550199', - phones: [], - getHeartbeat: async () => [], - }), + usePhonesStore: () => phones, useNotificationsStore: () => ({ addNotification: () => {} }), }) setActivePinia(createPinia()) return { store: useThreadsStore(), + phones, calls, respondWith: (threads: EntitiesMessageThread[]) => { responseThreads = threads @@ -87,6 +92,20 @@ function contact( } } +async function setupReadConversationHistory() { + const fixture = setupStore() + const other = { ...thread('other', 1), contact: '+18005550101' } + fixture.respondWith([thread('unread', 1), other]) + await fixture.store.loadThreads() + fixture.store.setThreadId('unread') + fixture.store.unreadOnly = true + await fixture.store.resetThreadUnreadCount('unread') + fixture.respondWith([other]) + await fixture.store.loadThreads() + await fixture.store.loadThreadMessages('other') + return fixture +} + test('Unread requests server-side filtering within the API limit', async () => { const { store, calls } = setupStore() store.unreadOnly = true @@ -225,3 +244,94 @@ for (const scenario of [ assert.deepEqual(store.visibleThreads, []) }) } + +test('a late poll cannot restore unread counts after mark-read succeeds', async () => { + const { store, deferNextLoad } = setupStore() + await store.loadThreads() + store.setThreadId('unread') + store.unreadOnly = true + const pending = Promise.withResolvers<{ data: EntitiesMessageThread[] }>() + deferNextLoad(pending.promise) + store.loadingThreads = true + const poll = store.loadThreads() + + await store.resetThreadUnreadCount('unread') + pending.resolve({ data: [thread('unread', 2)] }) + await poll + + assert.equal(store.currentThread?.unread_count, 0) + assert.deepEqual(store.visibleThreads, []) + assert.equal(store.loadingThreads, false) +}) + +test('refreshing an overlapping read preserves messages received after the read', async () => { + const { store, deferNextLoad, respondWith } = setupStore() + await store.loadThreads() + store.setThreadId('unread') + store.unreadOnly = true + const pending = Promise.withResolvers<{ data: EntitiesMessageThread[] }>() + deferNextLoad(pending.promise) + const poll = store.loadThreads() + + await store.resetThreadUnreadCount('unread') + respondWith([ + { + ...thread('unread', 1), + last_message_id: 'new-inbound-message', + last_message_content: 'Received after mark-read', + }, + ]) + pending.resolve({ data: [thread('unread', 2)] }) + await poll + + assert.equal(store.currentThread?.unread_count, 1) + assert.equal(store.currentThread.last_message_id, 'new-inbound-message') + assert.equal(store.visibleThreads.length, 1) + assert.equal(store.loadingThreads, false) +}) + +test('Back navigation restores an earlier read conversation in Unread', async () => { + const { store, calls } = await setupReadConversationHistory() + + store.setThreadId('unread') + + assert.equal(store.currentThread?.id, 'unread') + assert.equal(store.currentThread.unread_count, 0) + await store.loadThreadMessages('unread') + assert.equal(calls.at(-1)?.params?.contact, '+18005550100') + assert.deepEqual( + store.visibleThreads.map((item) => item.id), + ['other'], + ) +}) + +test('saved contacts refresh previously opened read conversations too', async () => { + const { store } = await setupReadConversationHistory() + await store.loadThreads([contact('Alice')]) + + store.setThreadId('unread') + + assert.equal(store.currentThread?.contact_details?.name, 'Alice') +}) + +for (const action of ['reset', 'delete', 'archive', 'owner', 'view']) { + test(`navigation cache does not restore conversations after ${action}`, async () => { + const { store, phones, respondWith } = await setupReadConversationHistory() + if (action === 'reset') store.resetState() + if (action === 'delete') await store.deleteThread('unread') + if (action === 'archive') { + await store.updateThread({ threadId: 'unread', isArchived: true }) + } + if (action === 'owner' || action === 'view') { + if (action === 'owner') phones.owner = '+18005550198' + if (action === 'view') store.archivedThreads = true + respondWith([]) + await store.loadThreads() + } + + store.setThreadId('unread') + + assert.equal(store.currentThread, null) + assert.equal(store.hasThreadId('unread'), false) + }) +} diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 9e56b106e..a703a3f63 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -12,7 +12,9 @@ export const useThreadsStore = defineStore('threads', () => { const archivedThreads = ref(false) const unreadOnly = ref(false) const retainedThread = ref(null) + const openedThreads = ref(new Map()) let threadRequestId = 0 + let readMutationGeneration = 0 const { apiFetch } = useApi() const notificationsStore = useNotificationsStore() @@ -64,21 +66,28 @@ export const useThreadsStore = defineStore('threads', () => { if (updatedThread.id === threadId.value) { retainedThread.value = replacement } + if ( + openedThreads.value.has(updatedThread.id) || + updatedThread.id === threadId.value + ) { + openedThreads.value.set(updatedThread.id, replacement) + } } async function loadThreads(savedContacts: EntitiesContact[] = []) { const requestId = ++threadRequestId + const readGeneration = readMutationGeneration const selectedThread = currentThread.value if (selectedThread) { + openedThreads.value.set(selectedThread.id, selectedThread) + } + for (const openedThread of openedThreads.value.values()) { for (const contact of savedContacts) { const matchesNumber = contact.phone_numbers.includes( - selectedThread.contact, + openedThread.contact, ) - if ( - matchesNumber || - selectedThread.contact_details?.id === contact.id - ) { - selectedThread.contact_details = matchesNumber ? contact : undefined + if (matchesNumber || openedThread.contact_details?.id === contact.id) { + openedThread.contact_details = matchesNumber ? contact : undefined } } } @@ -102,10 +111,19 @@ export const useThreadsStore = defineStore('threads', () => { }, ) if (requestId !== threadRequestId) return + if (readGeneration !== readMutationGeneration) { + await loadThreads() + return + } phonesStore.getHeartbeat().catch(console.error) retainedThread.value = currentThread.value threads.value = [...response.data] + for (const thread of response.data) { + if (openedThreads.value.has(thread.id)) { + openedThreads.value.set(thread.id, thread) + } + } } finally { if (requestId === threadRequestId) { loadingThreads.value = false @@ -134,11 +152,26 @@ export const useThreadsStore = defineStore('threads', () => { } function setThreadId(id: string | null) { + const previousThread = currentThread.value + if (previousThread) { + openedThreads.value.set(previousThread.id, previousThread) + } + const cached = id === null ? undefined : openedThreads.value.get(id) + const phonesStore = usePhonesStore() + const owner = phonesStore.owner ?? phonesStore.phones[0]?.phone_number retainedThread.value = id === null ? null : (threads.value.find((thread) => thread.id === id) ?? - (retainedThread.value?.id === id ? retainedThread.value : null)) + ((unreadOnly.value || loadingThreads.value) && + cached && + cached.owner === owner && + cached.is_archived === archivedThreads.value + ? cached + : null)) + if (retainedThread.value) { + openedThreads.value.set(retainedThread.value.id, retainedThread.value) + } threadId.value = id } @@ -158,6 +191,7 @@ export const useThreadsStore = defineStore('threads', () => { (thread) => thread.id !== payload.threadId, ) setThreadId(null) + openedThreads.value.delete(payload.threadId) notificationsStore.addNotification({ message: payload.isArchived ? 'Archived' : 'Unarchived', type: 'success', @@ -179,6 +213,7 @@ export const useThreadsStore = defineStore('threads', () => { body: { unread_count: 0 }, }, ) + readMutationGeneration++ replaceThread(response.data) } catch (error) { notificationsStore.addNotification({ @@ -201,6 +236,7 @@ export const useThreadsStore = defineStore('threads', () => { async function deleteThread(id: string) { await apiFetch(`/v1/message-threads/${id}`, { method: 'DELETE' }) setThreadId(null) + openedThreads.value.delete(id) notificationsStore.addNotification({ message: 'The message thread has been deleted successfully', type: 'success', @@ -209,9 +245,11 @@ export const useThreadsStore = defineStore('threads', () => { function resetState() { threadRequestId++ + readMutationGeneration = 0 threads.value = [] threadId.value = null retainedThread.value = null + openedThreads.value.clear() archivedThreads.value = false unreadOnly.value = false loadingThreads.value = true From 6ab09d125697eb2101b49858e9721445c1f59aa0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 15:18:35 +0200 Subject: [PATCH 05/11] fix(api): prevent stale notification status overwrites Increment send attempts atomically without overwriting concurrently scheduled state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- .../repositories/gorm_message_repository.go | 21 ++++++- .../gorm_message_repository_test.go | 55 +++++++++++++++++++ api/pkg/repositories/message_repository.go | 3 + api/pkg/services/message_service.go | 11 +--- api/pkg/services/message_service_test.go | 49 +++++++++++++++++ tests/helpers_test.go | 10 +++- 6 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 api/pkg/repositories/gorm_message_repository_test.go diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go index a4bf309f5..32cfdfe35 100644 --- a/api/pkg/repositories/gorm_message_repository.go +++ b/api/pkg/repositories/gorm_message_repository.go @@ -234,13 +234,32 @@ func (repository *gormMessageRepository) Update(ctx context.Context, message *en ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Save(message).Error; err != nil { + // Send attempts are owned by the atomic increment, not a loaded status snapshot. + if err := repository.db.WithContext(ctx).Omit("send_attempt_count").Save(message).Error; err != nil { return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with ID [%s]", message.ID)) } return nil } +func (repository *gormMessageRepository) IncrementSendAttemptCount(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + result := repository.db.WithContext(ctx). + Model(&entities.Message{}). + Where("user_id = ?", userID). + Where("id = ?", messageID). + Update("send_attempt_count", gorm.Expr("send_attempt_count + ?", 1)) + if result.Error != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(result.Error, "cannot increment send attempts for message [%s] for user [%s]", messageID, userID)) + } + if result.RowsAffected == 0 { + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, ErrCodeNotFound, "message [%s] for user [%s] does not exist", messageID, userID)) + } + return nil +} + // GetOutstanding fetches messages that still to be sent to the phone func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string) (*entities.Message, error) { ctx, span := repository.tracer.Start(ctx) diff --git a/api/pkg/repositories/gorm_message_repository_test.go b/api/pkg/repositories/gorm_message_repository_test.go new file mode 100644 index 000000000..c5001f7b0 --- /dev/null +++ b/api/pkg/repositories/gorm_message_repository_test.go @@ -0,0 +1,55 @@ +package repositories + +import ( + "context" + "strings" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestMessageStatusUpdatesPreserveConcurrentSendAttemptCounts(t *testing.T) { + pool := &messageThreadTestConnPool{} + db, err := gorm.Open(postgres.New(postgres.Config{Conn: pool, WithoutReturning: true}), &gorm.Config{DisableAutomaticPing: true}) + require.NoError(t, err) + logger := &messageThreadTestLogger{} + repository := NewGormMessageRepository(logger, telemetry.NewOtelLogger("test", logger), db) + + err = repository.Update(context.Background(), &entities.Message{ + ID: uuid.New(), + UserID: entities.UserID("user-id"), + Status: entities.MessageStatusScheduled, + SendAttemptCount: 0, + }) + + require.NoError(t, err) + require.NotEmpty(t, pool.statements) + update := pool.statements[len(pool.statements)-1] + assert.True(t, strings.HasPrefix(update.query, `UPDATE "messages"`)) + assert.Contains(t, update.query, `"status"=`) + assert.NotContains(t, update.query, `"send_attempt_count"=`, "a stale status snapshot must not reset an atomic attempt increment") +} + +func TestMessageSendAttemptIncrementOnlyUpdatesItsOwnedColumn(t *testing.T) { + pool := &messageThreadTestConnPool{} + db, err := gorm.Open(postgres.New(postgres.Config{Conn: pool, WithoutReturning: true}), &gorm.Config{DisableAutomaticPing: true}) + require.NoError(t, err) + logger := &messageThreadTestLogger{} + repository := NewGormMessageRepository(logger, telemetry.NewOtelLogger("test", logger), db) + + err = repository.IncrementSendAttemptCount(context.Background(), entities.UserID("user-id"), uuid.New()) + + require.NoError(t, err) + require.Len(t, pool.statements, 1) + update := pool.statements[0] + assert.Contains(t, update.query, `"send_attempt_count"=send_attempt_count +`) + assert.NotContains(t, update.query, `"status"=`) + assert.Contains(t, update.query, "WHERE user_id =") + assert.Contains(t, update.query, "AND id =") +} diff --git a/api/pkg/repositories/message_repository.go b/api/pkg/repositories/message_repository.go index c8f85fbb0..bad3c82d7 100644 --- a/api/pkg/repositories/message_repository.go +++ b/api/pkg/repositories/message_repository.go @@ -15,6 +15,9 @@ type MessageRepository interface { // Update a new entities.Message Update(ctx context.Context, message *entities.Message) error + // IncrementSendAttemptCount atomically increments attempts without changing message status. + IncrementSendAttemptCount(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error + // Load an entities.Message by ID Load(ctx context.Context, userID entities.UserID, messageID uuid.UUID) (*entities.Message, error) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 89c216817..57171d4a2 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -800,16 +800,11 @@ func (service *MessageService) HandleMessageNotificationSent(ctx context.Context ctxLogger := service.tracer.CtxLogger(service.logger, span) - message, err := service.repository.Load(ctx, params.UserID, params.ID) - if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot find message with id [%s]", params.ID)) - } - - if err = service.repository.Update(ctx, message.AddSendAttemptCount()); err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update message with id [%s] as expired", message.ID)) + if err := service.repository.IncrementSendAttemptCount(ctx, params.UserID, params.ID); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot increment send attempts for message with id [%s]", params.ID)) } - ctxLogger.Info(fmt.Sprintf("notification for message with id [%s] has been sent at [%s]", message.ID, params.Timestamp.String())) + ctxLogger.Info(fmt.Sprintf("notification for message with id [%s] has been sent at [%s]", params.ID, params.Timestamp.String())) return nil } diff --git a/api/pkg/services/message_service_test.go b/api/pkg/services/message_service_test.go index 263816f41..5a3790366 100644 --- a/api/pkg/services/message_service_test.go +++ b/api/pkg/services/message_service_test.go @@ -1,16 +1,65 @@ package services import ( + "context" "testing" "time" + "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" ) +type notificationMessageRepository struct { + repositories.MessageRepository + stored entities.Message +} + +func (repository *notificationMessageRepository) Load(context.Context, entities.UserID, uuid.UUID) (*entities.Message, error) { + stale := repository.stored + stale.Status = entities.MessageStatusPending + return &stale, nil +} + +func (repository *notificationMessageRepository) Update(_ context.Context, message *entities.Message) error { + repository.stored = *message + return nil +} + +func (repository *notificationMessageRepository) IncrementSendAttemptCount(context.Context, entities.UserID, uuid.UUID) error { + repository.stored.SendAttemptCount++ + return nil +} + +func TestNotificationSentDoesNotRestoreStalePendingStatus(t *testing.T) { + repository := ¬ificationMessageRepository{stored: entities.Message{ + ID: uuid.New(), + UserID: entities.UserID("user-id"), + Status: entities.MessageStatusScheduled, + }} + logger := &noopLogger{} + service := &MessageService{ + logger: logger, + tracer: telemetry.NewOtelLogger("test", logger), + repository: repository, + } + + err := service.HandleMessageNotificationSent(context.Background(), HandleMessageParams{ + ID: repository.stored.ID, + UserID: repository.stored.UserID, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + assert.Equal(t, entities.MessageStatus(entities.MessageStatusScheduled), repository.stored.Status) + assert.EqualValues(t, 1, repository.stored.SendAttemptCount) +} + func TestGetSendDelay_WithSendAt_ReturnsTimeUntil(t *testing.T) { service := &MessageService{} logger := &noopLogger{} diff --git a/tests/helpers_test.go b/tests/helpers_test.go index 797b2f09c..6d8106a33 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -466,6 +466,7 @@ func fireEvent(ctx context.Context, t *testing.T, phoneAPIKey string, messageID func pollMessageStatus(ctx context.Context, t *testing.T, messageID string, targetStatus string, timeout time.Duration) httpsms.Message { t.Helper() deadline := time.Now().Add(timeout) + lastStatus := "not observed" for time.Now().Before(deadline) { url := fmt.Sprintf("%s/v1/messages/%s", apiBaseURL, messageID) @@ -480,8 +481,11 @@ func pollMessageStatus(ctx context.Context, t *testing.T, messageID string, targ resp.Body.Close() if readErr == nil && resp.StatusCode == http.StatusOK { var result httpsms.MessageResponse - if json.Unmarshal(body, &result) == nil && result.Data.Status == targetStatus { - return result.Data + if json.Unmarshal(body, &result) == nil { + lastStatus = result.Data.Status + if result.Data.Status == targetStatus { + return result.Data + } } } } @@ -490,7 +494,7 @@ func pollMessageStatus(ctx context.Context, t *testing.T, messageID string, targ time.Sleep(500 * time.Millisecond) } - t.Fatalf("message %s did not reach status %q within %v", messageID, targetStatus, timeout) + t.Fatalf("message %s did not reach status %q within %v (last observed status: %q)", messageID, targetStatus, timeout, lastStatus) return httpsms.Message{} } From a55bf2ee3322268eb081c4868bf3fb6ba0b3c0ed Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 14:29:50 +0200 Subject: [PATCH 06/11] feat: add conditional load more for message threads Load 20 threads per page with server look-ahead and stable pagination ordering. Preserve loaded pages across polling and reset pagination when filters change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/docs/docs.go | 8 +- api/docs/swagger.json | 8 +- api/docs/swagger.yaml | 8 +- api/pkg/handlers/handler.go | 9 ++ api/pkg/handlers/message_thread_handler.go | 13 +- .../message_thread_handler_contacts_test.go | 46 +++++- .../gorm_message_thread_repository.go | 2 +- .../gorm_message_thread_repository_test.go | 2 +- api/pkg/responses/message_thead_responses.go | 2 + tests/unread_filter_integration_test.go | 58 ++++++- web/app/components/MessageThread.vue | 27 ++++ web/app/stores/threads.test.ts | 145 +++++++++++++++++- web/app/stores/threads.ts | 95 ++++++++++-- web/shared/types/api.ts | 5 + 14 files changed, 399 insertions(+), 29 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 67842b376..c05b9fa18 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1120,7 +1120,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", + "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. The has_more field indicates whether another page exists.", "consumes": [ "application/json" ], @@ -5552,6 +5552,7 @@ const docTemplate = `{ "type": "object", "required": [ "data", + "has_more", "message", "status" ], @@ -5562,6 +5563,11 @@ const docTemplate = `{ "$ref": "#/definitions/entities.MessageThread" } }, + "has_more": { + "description": "HasMore indicates that additional threads exist after this page.", + "type": "boolean", + "example": true + }, "message": { "type": "string", "example": "Request handled successfully" diff --git a/api/docs/swagger.json b/api/docs/swagger.json index c7966921d..f20f85a4e 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1117,7 +1117,7 @@ "ApiKeyAuth": [] } ], - "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", + "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. The has_more field indicates whether another page exists.", "consumes": [ "application/json" ], @@ -5549,6 +5549,7 @@ "type": "object", "required": [ "data", + "has_more", "message", "status" ], @@ -5559,6 +5560,11 @@ "$ref": "#/definitions/entities.MessageThread" } }, + "has_more": { + "description": "HasMore indicates that additional threads exist after this page.", + "type": "boolean", + "example": true + }, "message": { "type": "string", "example": "Request handled successfully" diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 964040158..c73983f7b 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1423,6 +1423,10 @@ definitions: items: $ref: '#/definitions/entities.MessageThread' type: array + has_more: + description: HasMore indicates that additional threads exist after this page. + example: true + type: boolean message: example: Request handled successfully type: string @@ -1431,6 +1435,7 @@ definitions: type: string required: - data + - has_more - message - status type: object @@ -2494,7 +2499,8 @@ paths: consumes: - application/json description: Get list of contacts which a phone number has communicated with - (threads). It will be sorted by timestamp in descending order. + (threads). It will be sorted by timestamp in descending order. The has_more + field indicates whether another page exists. parameters: - default: "+18005550199" description: owner phone number diff --git a/api/pkg/handlers/handler.go b/api/pkg/handlers/handler.go index 1f655b447..5640662d8 100644 --- a/api/pkg/handlers/handler.go +++ b/api/pkg/handlers/handler.go @@ -106,6 +106,15 @@ func (h *handler) responseOKWithTotal(c fiber.Ctx, message string, data interfac }) } +func (h *handler) responseOKWithHasMore(c fiber.Ctx, message string, data interface{}, hasMore bool) error { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "status": "success", + "message": message, + "data": data, + "has_more": hasMore, + }) +} + func (h *handler) responseCreated(c fiber.Ctx, message string, data interface{}) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "status": "success", diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 2a8aff3cb..beb7ffcb0 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -48,7 +48,7 @@ func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares . // Index returns message threads for a phone number // @Summary Get message threads for a phone number -// @Description Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. +// @Description Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. The has_more field indicates whether another page exists. // @Security ApiKeyAuth // @Tags MessageThreads // @Accept json @@ -84,13 +84,20 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while fetching message threads") } - threads, err := h.service.GetThreads(ctx, request.ToGetParams(h.userIDFomContext(c))) + params := request.ToGetParams(h.userIDFomContext(c)) + limit := params.Limit + params.Limit++ + threads, err := h.service.GetThreads(ctx, params) if err != nil { ctxLogger.Error(stacktrace.Propagatef(err, "cannot get message threads with params [%+#v]", request)) return h.responseInternalServerError(c) } - return h.responseOK(c, fmt.Sprintf("fetched %d message %s", len(*threads), h.pluralize("thread", len(*threads))), threads) + hasMore := len(*threads) > limit + if hasMore { + *threads = (*threads)[:limit] + } + return h.responseOKWithHasMore(c, fmt.Sprintf("fetched %d message %s", len(*threads), h.pluralize("thread", len(*threads))), threads, hasMore) } // Update an entities.MessageThread diff --git a/api/pkg/handlers/message_thread_handler_contacts_test.go b/api/pkg/handlers/message_thread_handler_contacts_test.go index 3ebca2595..a6e41c5e7 100644 --- a/api/pkg/handlers/message_thread_handler_contacts_test.go +++ b/api/pkg/handlers/message_thread_handler_contacts_test.go @@ -24,10 +24,12 @@ type messageThreadHandlerIndexRepositoryStub struct { repositories.MessageThreadRepository threads []entities.MessageThread unreadOnly bool + limit int } -func (stub *messageThreadHandlerIndexRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, unreadOnly bool, _ repositories.IndexParams) (*[]entities.MessageThread, error) { +func (stub *messageThreadHandlerIndexRepositoryStub) Index(_ context.Context, _ entities.UserID, _ string, _ bool, unreadOnly bool, params repositories.IndexParams) (*[]entities.MessageThread, error) { stub.unreadOnly = unreadOnly + stub.limit = params.Limit threads := make([]entities.MessageThread, len(stub.threads)) copy(threads, stub.threads) return &threads, nil @@ -97,3 +99,45 @@ func TestMessageThreadHandlerIndexRejectsInvalidUnreadFilter(t *testing.T) { defer response.Body.Close() assert.Equal(t, http.StatusUnprocessableEntity, response.StatusCode) } + +func TestMessageThreadIndexReportsWhetherAnotherPageExists(t *testing.T) { + for _, test := range []struct { + name string + count int + wantLen int + more bool + }{ + {name: "empty", count: 0, wantLen: 0}, + {name: "partial page", count: 19, wantLen: 19}, + {name: "exactly one page", count: 20, wantLen: 20}, + {name: "another page exists", count: 21, wantLen: 20, more: true}, + } { + t.Run(test.name, func(t *testing.T) { + repository := &messageThreadHandlerIndexRepositoryStub{threads: make([]entities.MessageThread, test.count)} + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, nil) + handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service) + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&limit=20", nil)) + + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, http.StatusOK, response.StatusCode) + var body struct { + Data []entities.MessageThread `json:"data"` + HasMore bool `json:"has_more"` + } + require.NoError(t, json.NewDecoder(response.Body).Decode(&body)) + assert.Len(t, body.Data, test.wantLen) + assert.Equal(t, test.more, body.HasMore) + assert.Equal(t, 21, repository.limit) + }) + } +} diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index 9f9424bf2..316f8ab26 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -263,7 +263,7 @@ func (repository *gormMessageThreadRepository) Index(ctx context.Context, userID } threads := new([]entities.MessageThread) - if err := query.Order("order_timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&threads).Error; err != nil { + if err := query.Order("order_timestamp DESC").Order("id DESC").Limit(params.Limit).Offset(params.Skip).Find(&threads).Error; err != nil { return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot fetch message threads with owner [%s] and params [%+#v]", owner, params)) } diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go index faca36178..6f2fde4a3 100644 --- a/api/pkg/repositories/gorm_message_thread_repository_test.go +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -55,7 +55,7 @@ func TestMessageThreadIndexFiltersUnreadBeforePagination(t *testing.T) { } else { assert.NotContains(t, query, "unread_count >") } - assert.Contains(t, query, "ORDER BY order_timestamp DESC LIMIT") + assert.Contains(t, query, "ORDER BY order_timestamp DESC,id DESC LIMIT") assert.Contains(t, query, "OFFSET") }) } diff --git a/api/pkg/responses/message_thead_responses.go b/api/pkg/responses/message_thead_responses.go index e47fec287..19e3e672b 100644 --- a/api/pkg/responses/message_thead_responses.go +++ b/api/pkg/responses/message_thead_responses.go @@ -6,6 +6,8 @@ import "github.com/NdoleStudio/httpsms/pkg/entities" type MessageThreadsResponse struct { response Data []entities.MessageThread `json:"data"` + // HasMore indicates that additional threads exist after this page. + HasMore bool `json:"has_more" example:"true"` } // MessageThreadResponse is the payload containing entities.MessageThread diff --git a/tests/unread_filter_integration_test.go b/tests/unread_filter_integration_test.go index 9545afab3..2a419189f 100644 --- a/tests/unread_filter_integration_test.go +++ b/tests/unread_filter_integration_test.go @@ -12,8 +12,18 @@ import ( "github.com/stretchr/testify/require" ) +type integrationThreadPage struct { + Data []integrationThread `json:"data"` + HasMore bool `json:"has_more"` +} + func fetchFilteredThreads(ctx context.Context, t *testing.T, owner string, archived, unreadOnly bool, limit, skip int) []integrationThread { t.Helper() + return fetchThreadPage(ctx, t, owner, archived, unreadOnly, limit, skip).Data +} + +func fetchThreadPage(ctx context.Context, t *testing.T, owner string, archived, unreadOnly bool, limit, skip int) integrationThreadPage { + t.Helper() query := url.Values{ "owner": {owner}, @@ -22,11 +32,9 @@ func fetchFilteredThreads(ctx context.Context, t *testing.T, owner string, archi "limit": {fmt.Sprint(limit)}, "skip": {fmt.Sprint(skip)}, } - var response struct { - Data []integrationThread `json:"data"` - } + var response integrationThreadPage requestJSON(ctx, t, http.MethodGet, "/v1/message-threads?"+query.Encode(), userAPIKey, nil, http.StatusOK, &response) - return response.Data + return response } func TestMessageThreadUnreadFilter(t *testing.T) { @@ -88,3 +96,45 @@ func TestMessageThreadUnreadFilterRejectsInvalidValue(t *testing.T) { } requestJSON(context.Background(), t, http.MethodGet, "/v1/message-threads?"+query.Encode(), userAPIKey, nil, http.StatusUnprocessableEntity, nil) } + +func TestMessageThreadPaginationHasMore(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + timestamp := time.Now().Add(-time.Minute).Truncate(time.Second) + for index := 0; index < 20; index++ { + receiveInbound(ctx, t, phone.PhoneAPIKey, fmt.Sprintf("+1800554%04d", index), phone.PhoneNumber, "Pagination thread", timestamp) + } + + var first integrationThreadPage + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + first = fetchThreadPage(ctx, t, phone.PhoneNumber, false, false, 20, 0) + if len(first.Data) == 20 { + break + } + time.Sleep(100 * time.Millisecond) + } + require.Len(t, first.Data, 20) + assert.False(t, first.HasMore, "exactly 20 conversations must not offer Load More") + + receiveInbound(ctx, t, phone.PhoneAPIKey, "+18005540020", phone.PhoneNumber, "One extra thread", timestamp) + deadline = time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + first = fetchThreadPage(ctx, t, phone.PhoneNumber, false, false, 20, 0) + if first.HasMore { + break + } + time.Sleep(100 * time.Millisecond) + } + require.True(t, first.HasMore) + require.Len(t, first.Data, 20) + + second := fetchThreadPage(ctx, t, phone.PhoneNumber, false, false, 20, 20) + require.Len(t, second.Data, 1) + assert.False(t, second.HasMore) + seen := map[string]bool{} + for _, thread := range first.Data { + seen[thread.ID] = true + } + assert.False(t, seen[second.Data[0].ID], "equal timestamps must not duplicate threads between pages") +} diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 5ef623c95..b0a01dd0c 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -49,6 +49,18 @@ function onInstallApp() { }) } +async function loadMoreThreads() { + try { + await threadsStore.loadMoreThreads() + } catch (error) { + notificationsStore.addNotification({ + type: 'error', + message: 'More conversations could not be loaded', + }) + console.error(error) + } +} + function isWhatsappFormatted(thread: EntitiesMessageThread): boolean { const phone = phonesStore.phones.find((x) => x.phone_number === thread.owner) return isUrlNotificationToken(phone?.fcm_token) @@ -141,6 +153,7 @@ function threadAvatarInitial(thread: EntitiesMessageThread): string { !threadsStore.loadingThreads && threadsStore.unreadOnly && threadsStore.visibleThreads.length === 0 && + !threadsStore.hasMoreThreads && phonesStore.owner " class="text-medium-emphasis text-center mt-6" @@ -244,5 +257,19 @@ function threadAvatarInitial(thread: EntitiesMessageThread): string { +
+ + Load More + +
diff --git a/web/app/stores/threads.test.ts b/web/app/stores/threads.test.ts index f4c81d3ac..6911f8fd6 100644 --- a/web/app/stores/threads.test.ts +++ b/web/app/stores/threads.test.ts @@ -56,7 +56,13 @@ function setupStore() { if (path === '/v1/messages') return { data: [] } const pending = nextResponse nextResponse = undefined - return pending ?? { data: responseThreads } + if (pending) return { ...(await pending), has_more: false } + const skip = Number(options.params?.skip ?? 0) + const limit = Number(options.params?.limit ?? 20) + return { + data: responseThreads.slice(skip, skip + limit), + has_more: responseThreads.length > skip + limit, + } }, }), usePhonesStore: () => phones, @@ -335,3 +341,140 @@ for (const action of ['reset', 'delete', 'archive', 'owner', 'view']) { assert.equal(store.hasThreadId('unread'), false) }) } + +function manyThreads(count: number) { + return Array.from({ length: count }, (_, index) => ({ + ...thread(`page-${index}`, 1), + contact: `+1800555${String(index).padStart(4, '0')}`, + })) +} + +for (const count of [0, 19, 20, 21]) { + test(`Load More is available only when more than 20 threads exist (${count})`, async () => { + const { store, respondWith } = setupStore() + respondWith(manyThreads(count)) + + await store.loadThreads() + + assert.equal(store.threads.length, Math.min(count, 20)) + assert.equal(store.hasMoreThreads, count > 20) + }) +} + +test('Load More expands the loaded list and disappears on the final page', async () => { + const { store, respondWith, calls } = setupStore() + respondWith(manyThreads(45)) + await store.loadThreads() + + await store.loadMoreThreads() + assert.equal(store.threads.length, 40) + assert.equal(store.hasMoreThreads, true) + await store.loadMoreThreads() + assert.equal(store.threads.length, 45) + assert.equal(new Set(store.threads.map((item) => item.id)).size, 45) + assert.equal(store.hasMoreThreads, false) + assert.equal(store.loadingMoreThreads, false) + const completedCalls = calls.length + await store.loadMoreThreads() + assert.equal(calls.length, completedCalls) +}) + +test('polling preserves loaded pages and changing filters resets them', async () => { + const { store, respondWith } = setupStore() + respondWith(manyThreads(45)) + await store.loadThreads() + await store.loadMoreThreads() + + await store.loadThreads() + assert.equal(store.threads.length, 40) + + store.unreadOnly = true + await store.loadThreads() + assert.equal(store.threads.length, 20) + await store.loadMoreThreads() + store.archivedThreads = true + await store.loadThreads() + assert.equal(store.threads.length, 20) +}) + +test('switching phones resets loaded pages', async () => { + const { store, respondWith, phones } = setupStore() + respondWith(manyThreads(45)) + await store.loadThreads() + await store.loadMoreThreads() + + phones.owner = '+18005550198' + await store.loadThreads() + + assert.equal(store.threads.length, 20) +}) + +test('Load More failure preserves rows and permits a retry', async () => { + const { store, respondWith, deferNextLoad } = setupStore() + respondWith(manyThreads(45)) + await store.loadThreads() + const pending = Promise.withResolvers<{ data: EntitiesMessageThread[] }>() + deferNextLoad(pending.promise) + + const loading = store.loadMoreThreads() + pending.reject(new Error('Network failed')) + await assert.rejects(loading, /Network failed/) + + assert.equal(store.threads.length, 20) + assert.equal(store.hasMoreThreads, true) + assert.equal(store.loadingMoreThreads, false) + await store.loadMoreThreads() + assert.equal(store.threads.length, 40) +}) + +test('new activity does not close an open conversation pushed beyond the loaded page', async () => { + const { store, respondWith } = setupStore() + const existing = manyThreads(45) + respondWith(existing) + await store.loadThreads() + store.setThreadId('page-19') + respondWith([thread('newest', 1), ...existing]) + + await store.loadThreads() + + assert.equal(store.currentThread?.id, 'page-19') + await store.loadThreadMessages('page-19') +}) + +test('a second Load More click is ignored while the first is running', async () => { + const { store, respondWith, deferNextLoad } = setupStore() + const existing = manyThreads(65) + respondWith(existing) + await store.loadThreads() + const pending = Promise.withResolvers<{ data: EntitiesMessageThread[] }>() + deferNextLoad(pending.promise) + + const loading = store.loadMoreThreads() + assert.equal(store.loadingMoreThreads, true) + await store.loadMoreThreads() + pending.resolve({ data: existing.slice(0, 20) }) + await loading + + assert.equal(store.threads.length, 40) + assert.equal(store.loadingMoreThreads, false) +}) + +test('changing filters cancels an older Load More result', async () => { + const { store, respondWith, deferNextLoad } = setupStore() + const existing = manyThreads(45) + respondWith(existing) + await store.loadThreads() + const pending = Promise.withResolvers<{ data: EntitiesMessageThread[] }>() + deferNextLoad(pending.promise) + + const loading = store.loadMoreThreads() + store.unreadOnly = true + await store.loadThreads() + pending.resolve({ data: existing.slice(0, 20) }) + await loading + + assert.equal(store.threads.length, 20) + assert.equal(store.loadingMoreThreads, false) + await store.loadMoreThreads() + assert.equal(store.threads.length, 40) +}) diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index a703a3f63..4c240d055 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -5,10 +5,17 @@ import type { EntitiesMessage, } from '~~/shared/types/api' +const PAGE_SIZE = 20 + export const useThreadsStore = defineStore('threads', () => { const threads = ref([]) const threadId = ref(null) const loadingThreads = ref(true) + const loadingMoreThreads = ref(false) + const hasMoreThreads = ref(false) + let pageCount = 1 + let pageContext = '' + let loadMoreGeneration = 0 const archivedThreads = ref(false) const unreadOnly = ref(false) const retainedThread = ref(null) @@ -21,7 +28,7 @@ export const useThreadsStore = defineStore('threads', () => { const currentThread = computed(() => { return ( threads.value.find((x) => x.id === threadId.value) ?? - ((unreadOnly.value || loadingThreads.value) && + ((unreadOnly.value || loadingThreads.value || hasMoreThreads.value) && retainedThread.value?.id === threadId.value ? retainedThread.value : null) @@ -94,21 +101,39 @@ export const useThreadsStore = defineStore('threads', () => { const phonesStore = usePhonesStore() if (phonesStore.owner === null && phonesStore.phones.length === 0) { loadingThreads.value = false + hasMoreThreads.value = false return } + const owner = phonesStore.owner ?? phonesStore.phones[0]?.phone_number + const archived = archivedThreads.value + const unread = unreadOnly.value + const context = JSON.stringify([owner, archived, unread]) + if (context !== pageContext) { + pageContext = context + pageCount = 1 + hasMoreThreads.value = false + loadingMoreThreads.value = false + loadMoreGeneration++ + } + try { - const response = await apiFetch<{ data: EntitiesMessageThread[] }>( - '/v1/message-threads', - { - params: { - owner: phonesStore.owner ?? phonesStore.phones[0]?.phone_number, - limit: 20, - is_archived: archivedThreads.value, - unread_only: unreadOnly.value, - contacts: true, - }, - }, + const responses = await Promise.all( + Array.from({ length: pageCount }, (_, page) => + apiFetch<{ data: EntitiesMessageThread[]; has_more: boolean }>( + '/v1/message-threads', + { + params: { + owner, + limit: PAGE_SIZE, + skip: page * PAGE_SIZE, + is_archived: archived, + unread_only: unread, + contacts: true, + }, + }, + ), + ), ) if (requestId !== threadRequestId) return if (readGeneration !== readMutationGeneration) { @@ -118,8 +143,17 @@ export const useThreadsStore = defineStore('threads', () => { phonesStore.getHeartbeat().catch(console.error) retainedThread.value = currentThread.value - threads.value = [...response.data] - for (const thread of response.data) { + const combined = new Map() + let hasMore = false + for (const response of responses) { + hasMore = response.has_more + for (const thread of response.data) { + if (!combined.has(thread.id)) combined.set(thread.id, thread) + } + } + threads.value = [...combined.values()] + hasMoreThreads.value = hasMore + for (const thread of threads.value) { if (openedThreads.value.has(thread.id)) { openedThreads.value.set(thread.id, thread) } @@ -131,6 +165,29 @@ export const useThreadsStore = defineStore('threads', () => { } } + async function loadMoreThreads() { + if ( + loadingThreads.value || + loadingMoreThreads.value || + !hasMoreThreads.value + ) + return + + const generation = ++loadMoreGeneration + const previousPageCount = pageCount + pageCount++ + loadingMoreThreads.value = true + try { + await loadThreads() + } catch (error) { + if (generation !== loadMoreGeneration) return + pageCount = previousPageCount + throw error + } finally { + if (generation === loadMoreGeneration) loadingMoreThreads.value = false + } + } + async function loadThreadMessages( id: string | null, ): Promise { @@ -163,7 +220,7 @@ export const useThreadsStore = defineStore('threads', () => { id === null ? null : (threads.value.find((thread) => thread.id === id) ?? - ((unreadOnly.value || loadingThreads.value) && + ((unreadOnly.value || loadingThreads.value || hasMoreThreads.value) && cached && cached.owner === owner && cached.is_archived === archivedThreads.value @@ -246,6 +303,11 @@ export const useThreadsStore = defineStore('threads', () => { function resetState() { threadRequestId++ readMutationGeneration = 0 + loadMoreGeneration++ + pageCount = 1 + pageContext = '' + hasMoreThreads.value = false + loadingMoreThreads.value = false threads.value = [] threadId.value = null retainedThread.value = null @@ -259,6 +321,8 @@ export const useThreadsStore = defineStore('threads', () => { threads, threadId, loadingThreads, + loadingMoreThreads, + hasMoreThreads, archivedThreads, unreadOnly, visibleThreads, @@ -266,6 +330,7 @@ export const useThreadsStore = defineStore('threads', () => { hasThread, hasThreadId, loadThreads, + loadMoreThreads, loadThreadMessages, setThreadId, toggleArchive, diff --git a/web/shared/types/api.ts b/web/shared/types/api.ts index 9ecb82efb..a01eeaecb 100644 --- a/web/shared/types/api.ts +++ b/web/shared/types/api.ts @@ -784,6 +784,11 @@ export interface ResponsesMessageThreadResponse { export interface ResponsesMessageThreadsResponse { data: EntitiesMessageThread[]; + /** + * HasMore indicates that additional threads exist after this page. + * @example true + */ + has_more: boolean; /** @example "Request handled successfully" */ message: string; /** @example "success" */ From 7e59edb702d922f3e6eb3a7f3686cbd548cf31cc Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 16:27:00 +0200 Subject: [PATCH 07/11] fix: restore deep links to older message threads Resolve conversations independently of paging and restore phone/archive context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/docs/docs.go | 56 ++++++++++++ api/docs/swagger.json | 56 ++++++++++++ api/docs/swagger.yaml | 37 ++++++++ api/pkg/handlers/message_thread_handler.go | 39 +++++++- .../handlers/message_thread_handler_test.go | 70 ++++++++++++++- api/pkg/services/message_thread_service.go | 12 ++- tests/unread_filter_integration_test.go | 13 +++ web/app/pages/threads/[id]/index.vue | 48 +++++++++- web/app/stores/threads.test.ts | 90 ++++++++++++++++++- web/app/stores/threads.ts | 33 ++++++- 10 files changed, 443 insertions(+), 11 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index c05b9fa18..3a4270193 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1210,6 +1210,62 @@ const docTemplate = `{ } }, "/message-threads/{messageThreadID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a conversation belonging to the authenticated user, including matching contact details when available.", + "produces": [ + "application/json" + ], + "tags": [ + "MessageThreads" + ], + "summary": "Get a message thread", + "parameters": [ + { + "type": "string", + "description": "ID of the message thread", + "name": "messageThreadID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageThreadResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, "put": { "security": [ { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index f20f85a4e..b744dfa29 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1207,6 +1207,62 @@ } }, "/message-threads/{messageThreadID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a conversation belonging to the authenticated user, including matching contact details when available.", + "produces": [ + "application/json" + ], + "tags": [ + "MessageThreads" + ], + "summary": "Get a message thread", + "parameters": [ + { + "type": "string", + "description": "ID of the message thread", + "name": "messageThreadID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessageThreadResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, "put": { "security": [ { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index c73983f7b..9b8b82ca4 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -2605,6 +2605,43 @@ paths: summary: Delete a message thread from the database. tags: - MessageThreads + get: + description: Returns a conversation belonging to the authenticated user, including + matching contact details when available. + parameters: + - description: ID of the message thread + in: path + name: messageThreadID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.MessageThreadResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Get a message thread + tags: + - MessageThreads put: consumes: - application/json diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index beb7ffcb0..161330109 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -42,6 +42,7 @@ func NewMessageThreadHandler( // RegisterRoutes registers the routes for the MessageHandler func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { h.register(router, fiber.MethodGet, "/v1/message-threads", middlewares, h.Index) + h.register(router, fiber.MethodGet, "/v1/message-threads/:messageThreadID", middlewares, h.Get) h.register(router, fiber.MethodPut, "/v1/message-threads/:messageThreadID", middlewares, h.Update) h.register(router, fiber.MethodDelete, "/v1/message-threads/:messageThreadID", middlewares, h.Delete) } @@ -100,6 +101,42 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { return h.responseOKWithHasMore(c, fmt.Sprintf("fetched %d message %s", len(*threads), h.pluralize("thread", len(*threads))), threads, hasMore) } +// Get returns a single conversation independently of list filters and pagination. +// @Summary Get a message thread +// @Description Returns a conversation belonging to the authenticated user, including matching contact details when available. +// @Security ApiKeyAuth +// @Tags MessageThreads +// @Produce json +// @Param messageThreadID path string true "ID of the message thread" +// @Success 200 {object} responses.MessageThreadResponse +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /message-threads/{messageThreadID} [get] +func (h *MessageThreadHandler) Get(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + id := c.Params("messageThreadID") + if errors := h.validator.ValidateUUID(id, "messageThreadID"); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("invalid message thread ID [%s]", id)) + return h.responseUnprocessableEntity(c, errors, "validation errors while fetching a message thread") + } + thread, err := h.service.GetThread(ctx, h.userIDFomContext(c), uuid.MustParse(id), true) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", id)) + } + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot fetch message thread [%s]", id)) + return h.responseInternalServerError(c) + } + if !h.authorizePhoneAPIKey(c, thread.Owner) { + return h.responsePhoneAPIKeyUnauthorized(c, thread.Owner, h.userFromContext(c)) + } + return h.responseOK(c, "message thread fetched successfully", thread) +} + // Update an entities.MessageThread // @Summary Update a message thread // @Description Updates the details of a message thread @@ -169,7 +206,7 @@ func (h *MessageThreadHandler) Delete(c fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while deleting a thread thread") } - thread, err := h.service.GetThread(ctx, h.userIDFomContext(c), uuid.MustParse(messageThreadID)) + thread, err := h.service.GetThread(ctx, h.userIDFomContext(c), uuid.MustParse(messageThreadID), false) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { return h.responseNotFound(c, fmt.Sprintf("cannot find thread thread with ID [%s]", messageThreadID)) } diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go index a9b6ccbe4..e545adc31 100644 --- a/api/pkg/handlers/message_thread_handler_test.go +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -23,7 +23,9 @@ import ( "gorm.io/gorm" ) -type messageThreadHandlerRepositoryStub struct{} +type messageThreadHandlerRepositoryStub struct { + load func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) +} func (stub *messageThreadHandlerRepositoryStub) Store(context.Context, *entities.MessageThread) error { return nil @@ -41,7 +43,10 @@ func (stub *messageThreadHandlerRepositoryStub) LoadByOwnerContact(context.Conte return nil, nil } -func (stub *messageThreadHandlerRepositoryStub) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { +func (stub *messageThreadHandlerRepositoryStub) Load(ctx context.Context, userID entities.UserID, id uuid.UUID) (*entities.MessageThread, error) { + if stub.load != nil { + return stub.load(ctx, userID, id) + } return nil, nil } @@ -90,6 +95,67 @@ func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testin require.Equal(t, "cannot find message thread with ID ["+messageThreadID.String()+"]", payload.Message) } +func TestMessageThreadHandlerGetLoadsIndividualUserScopedThread(t *testing.T) { + for _, test := range []struct { + name string + status int + }{ + {name: "existing", status: http.StatusOK}, + {name: "missing", status: http.StatusNotFound}, + {name: "another user", status: http.StatusNotFound}, + {name: "invalid UUID", status: http.StatusUnprocessableEntity}, + {name: "repository failure", status: http.StatusInternalServerError}, + } { + t.Run(test.name, func(t *testing.T) { + id := uuid.New() + contact := &entities.Contact{ID: uuid.New(), Name: "Alice", PhoneNumbers: []string{"+18005550100"}} + repository := &messageThreadHandlerRepositoryStub{ + load: func(_ context.Context, userID entities.UserID, threadID uuid.UUID) (*entities.MessageThread, error) { + require.Equal(t, entities.UserID("user-id"), userID) + require.Equal(t, id, threadID) + if test.name == "missing" || test.name == "another user" { + return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + } + if test.name == "repository failure" { + return nil, stacktrace.NewError("database unavailable") + } + return &entities.MessageThread{ID: id, UserID: userID, Owner: "+18005550199", Contact: "+18005550100"}, nil + }, + } + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + provider := &messageThreadHandlerContactProviderStub{contacts: map[string]*entities.Contact{"+18005550100": contact}} + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil, provider) + handler := NewMessageThreadHandler(logger, tracer, validators.NewMessageThreadHandlerValidator(logger, tracer), service) + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + pathID := id.String() + if test.name == "invalid UUID" { + pathID = "invalid" + } + + response, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/message-threads/"+pathID, nil)) + + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, test.status, response.StatusCode) + if test.status == http.StatusOK { + var payload struct { + Data entities.MessageThread `json:"data"` + } + require.NoError(t, json.NewDecoder(response.Body).Decode(&payload)) + require.Equal(t, id, payload.Data.ID) + require.NotNil(t, payload.Data.ContactDetails) + require.Equal(t, "Alice", payload.Data.ContactDetails.Name) + } + }) + } +} + type messageThreadHandlerNoopLogger struct{} var _ telemetry.Logger = (*messageThreadHandlerNoopLogger)(nil) diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index 283cd4415..506ae56e9 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -322,8 +322,8 @@ func (service *MessageThreadService) GetThreads(ctx context.Context, params Mess } // GetThread fetches an entities.MessageThread message thread by the ID -func (service *MessageThreadService) GetThread(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID) (*entities.MessageThread, error) { - ctx, span := service.tracer.Start(ctx) +func (service *MessageThreadService) GetThread(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID, withContacts bool) (*entities.MessageThread, error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() thread, err := service.repository.Load(ctx, userID, messageThreadID) @@ -331,6 +331,14 @@ func (service *MessageThreadService) GetThread(ctx context.Context, userID entit return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCodef(err, stacktrace.GetCode(err), "could not fetch thread with ID [%s] for user [%s]", messageThreadID, userID)) } + if withContacts { + contacts, contactErr := service.contactService.GetContactMap(ctx, userID, []string{thread.Contact}) + if contactErr != nil { + ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(contactErr, "cannot resolve contact for thread [%s]", messageThreadID))) + } else { + thread.ContactDetails = contacts[thread.Contact] + } + } return thread, nil } diff --git a/tests/unread_filter_integration_test.go b/tests/unread_filter_integration_test.go index 2a419189f..058bbf758 100644 --- a/tests/unread_filter_integration_test.go +++ b/tests/unread_filter_integration_test.go @@ -137,4 +137,17 @@ func TestMessageThreadPaginationHasMore(t *testing.T) { seen[thread.ID] = true } assert.False(t, seen[second.Data[0].ID], "equal timestamps must not duplicate threads between pages") + + var individual struct { + Data integrationThread `json:"data"` + } + path := "/v1/message-threads/" + second.Data[0].ID + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &individual) + assert.Equal(t, second.Data[0].ID, individual.Data.ID, "an older conversation must load independently of the first page") + assert.Equal(t, phone.PhoneNumber, individual.Data.Owner) + archiveThread(ctx, t, individual.Data.ID) + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &individual) + assert.True(t, individual.Data.IsArchived, "direct lookup must also resolve archived conversations") + requestJSON(ctx, t, http.MethodGet, path, systemAPIKey, nil, http.StatusNotFound, nil) + requestJSON(ctx, t, http.MethodGet, "/v1/message-threads/invalid", userAPIKey, nil, http.StatusUnprocessableEntity, nil) } diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 720e6345d..3faec47b3 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -21,7 +21,12 @@ import Pusher from 'pusher-js' import type { Channel } from 'pusher-js' import { isValidPhoneNumber } from 'libphonenumber-js' import { storeToRefs } from 'pinia' -import type { EntitiesContact, EntitiesMessage } from '~~/shared/types/api' +import { getApiErrorMessage, toApiError } from '~/utils/api-error' +import type { + EntitiesContact, + EntitiesMessage, + EntitiesMessageThread, +} from '~~/shared/types/api' import { isUrlNotificationToken, formatWhatsappText, @@ -203,6 +208,45 @@ async function loadData() { await threadsStore.loadThreads() } + if (route.params.id !== threadId) return + if (!threadsStore.hasThreadId(threadId)) { + let requestedThread: EntitiesMessageThread + try { + requestedThread = await threadsStore.fetchThread(threadId) + } catch (error) { + if (route.params.id !== threadId) return + const status = toApiError(error).status + if (status === 404 || status === 422) { + notificationsStore.addNotification({ + type: 'error', + message: 'The conversation could not be found', + }) + await router.push('/threads') + return + } + notificationsStore.addNotification({ + type: 'error', + message: getApiErrorMessage( + error, + 'The conversation could not be loaded', + ), + }) + throw error + } + if (route.params.id !== threadId) return + const contextChanged = + phonesStore.owner !== requestedThread.owner || + threadsStore.archivedThreads !== requestedThread.is_archived + if (phonesStore.owner !== requestedThread.owner) { + await authStore.updateUser({ owner: requestedThread.owner }) + } + if (route.params.id !== threadId) return + threadsStore.archivedThreads = requestedThread.is_archived + if (contextChanged) await threadsStore.loadThreads() + if (route.params.id !== threadId) return + threadsStore.setThreadId(threadId) + } + if (!threadsStore.hasThreadId(threadId)) { await router.push('/threads') return @@ -312,7 +356,7 @@ watch( if (!previousThreadId || threadId === previousThreadId) return threadsStore.setThreadId(threadId) - void loadMessages() + void loadData() }, ) diff --git a/web/app/stores/threads.test.ts b/web/app/stores/threads.test.ts index 6911f8fd6..817a78beb 100644 --- a/web/app/stores/threads.test.ts +++ b/web/app/stores/threads.test.ts @@ -29,6 +29,8 @@ function thread(id: string, unreadCount: number): EntitiesMessageThread { function setupStore() { const calls: { path: string; params?: Record }[] = [] let responseThreads = [thread('unread', 2), thread('read', 0)] + const individualThreads = new Map() + let nextLookup: Promise<{ data: EntitiesMessageThread }> | undefined let nextResponse: Promise<{ data: EntitiesMessageThread[] }> | undefined const phones = { owner: '+18005550199', @@ -44,7 +46,7 @@ function setupStore() { options: { params?: Record method?: string - }, + } = {}, ) => { calls.push({ path, params: options.params }) if (options.method === 'PUT') { @@ -54,6 +56,20 @@ function setupStore() { return { data: thread('unread', 0) } } if (path === '/v1/messages') return { data: [] } + if ( + (!options.method || options.method === 'GET') && + path.startsWith('/v1/message-threads/') + ) { + if (nextLookup) { + const pending = nextLookup + nextLookup = undefined + return pending + } + const item = individualThreads.get(path.split('/').at(-1) ?? '') + if (!item) + throw Object.assign(new Error('Thread not found'), { status: 404 }) + return { data: item } + } const pending = nextResponse nextResponse = undefined if (pending) return { ...(await pending), has_more: false } @@ -76,6 +92,12 @@ function setupStore() { respondWith: (threads: EntitiesMessageThread[]) => { responseThreads = threads }, + respondToLookup: (item: EntitiesMessageThread) => { + individualThreads.set(item.id, item) + }, + deferLookup: (response: Promise<{ data: EntitiesMessageThread }>) => { + nextLookup = response + }, deferNextLoad: (response: Promise<{ data: EntitiesMessageThread[] }>) => { nextResponse = response }, @@ -478,3 +500,69 @@ test('changing filters cancels an older Load More result', async () => { await store.loadMoreThreads() assert.equal(store.threads.length, 40) }) + +test('a cold deep link resolves an older conversation independently of page one', async () => { + const { store, respondWith, respondToLookup, calls } = setupStore() + const rows = manyThreads(65) + const requested = { + ...thread('page-60', 0), + contact: '+18005550060', + contact_details: contact('Older contact', ['+18005550060']), + } + respondWith(rows) + respondToLookup(requested) + store.setThreadId(requested.id) + + await store.fetchThread(requested.id) + await store.loadThreads() + store.setThreadId(requested.id) + + assert.equal(store.threads.length, 20) + assert.equal(store.currentThread?.id, requested.id) + assert.equal(store.currentThread.contact_details?.name, 'Older contact') + await store.loadThreadMessages(requested.id) + assert.equal(calls.at(-1)?.params?.contact, requested.contact) + assert.equal( + calls.filter((call) => call.path === '/v1/message-threads').length, + 1, + ) +}) + +test('individual lookup preserves a real 404 instead of searching every page', async () => { + const { store, calls } = setupStore() + + await assert.rejects(store.fetchThread('missing'), { status: 404 }) + + assert.equal(calls.length, 1) + assert.equal(calls[0]?.path, '/v1/message-threads/missing') +}) + +test('a successfully fetched conversation remains selectable independently of list metadata', async () => { + const { store, respondWith, respondToLookup } = setupStore() + respondWith(manyThreads(20)) + const requested = thread('directly-requested', 0) + respondToLookup(requested) + await store.loadThreads() + assert.equal(store.hasMoreThreads, false) + + await store.fetchThread(requested.id) + store.setThreadId(requested.id) + + assert.equal(store.currentThread?.id, requested.id) + assert.equal(store.threads.length, 20) +}) + +test('resetting the session cancels an older individual lookup', async () => { + const { store, deferLookup } = setupStore() + const pending = Promise.withResolvers<{ data: EntitiesMessageThread }>() + deferLookup(pending.promise) + const loading = store.fetchThread('old-session') + + store.resetState() + pending.resolve({ data: thread('old-session', 0) }) + + await assert.rejects(loading, { name: 'AbortError' }) + store.unreadOnly = true + store.setThreadId('old-session') + assert.equal(store.currentThread, null) +}) diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 4c240d055..7d5be53e5 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -20,7 +20,9 @@ export const useThreadsStore = defineStore('threads', () => { const unreadOnly = ref(false) const retainedThread = ref(null) const openedThreads = ref(new Map()) + const fetchedThreadIds = ref(new Set()) let threadRequestId = 0 + let stateGeneration = 0 let readMutationGeneration = 0 const { apiFetch } = useApi() const notificationsStore = useNotificationsStore() @@ -28,7 +30,11 @@ export const useThreadsStore = defineStore('threads', () => { const currentThread = computed(() => { return ( threads.value.find((x) => x.id === threadId.value) ?? - ((unreadOnly.value || loadingThreads.value || hasMoreThreads.value) && + ((unreadOnly.value || + loadingThreads.value || + hasMoreThreads.value || + (threadId.value !== null && + fetchedThreadIds.value.has(threadId.value))) && retainedThread.value?.id === threadId.value ? retainedThread.value : null) @@ -42,7 +48,7 @@ export const useThreadsStore = defineStore('threads', () => { ) const hasThread = computed( - () => threadId.value != null && !loadingThreads.value, + () => currentThread.value !== null && !loadingThreads.value, ) function hasThreadId(id: string): boolean { @@ -188,6 +194,19 @@ export const useThreadsStore = defineStore('threads', () => { } } + async function fetchThread(id: string): Promise { + const generation = stateGeneration + const response = await apiFetch<{ data: EntitiesMessageThread }>( + `/v1/message-threads/${id}`, + ) + if (generation !== stateGeneration) { + throw new DOMException('Conversation lookup was canceled', 'AbortError') + } + openedThreads.value.set(response.data.id, response.data) + fetchedThreadIds.value.add(response.data.id) + return response.data + } + async function loadThreadMessages( id: string | null, ): Promise { @@ -220,7 +239,10 @@ export const useThreadsStore = defineStore('threads', () => { id === null ? null : (threads.value.find((thread) => thread.id === id) ?? - ((unreadOnly.value || loadingThreads.value || hasMoreThreads.value) && + ((unreadOnly.value || + loadingThreads.value || + hasMoreThreads.value || + fetchedThreadIds.value.has(id)) && cached && cached.owner === owner && cached.is_archived === archivedThreads.value @@ -249,6 +271,7 @@ export const useThreadsStore = defineStore('threads', () => { ) setThreadId(null) openedThreads.value.delete(payload.threadId) + fetchedThreadIds.value.delete(payload.threadId) notificationsStore.addNotification({ message: payload.isArchived ? 'Archived' : 'Unarchived', type: 'success', @@ -294,6 +317,7 @@ export const useThreadsStore = defineStore('threads', () => { await apiFetch(`/v1/message-threads/${id}`, { method: 'DELETE' }) setThreadId(null) openedThreads.value.delete(id) + fetchedThreadIds.value.delete(id) notificationsStore.addNotification({ message: 'The message thread has been deleted successfully', type: 'success', @@ -302,6 +326,7 @@ export const useThreadsStore = defineStore('threads', () => { function resetState() { threadRequestId++ + stateGeneration++ readMutationGeneration = 0 loadMoreGeneration++ pageCount = 1 @@ -312,6 +337,7 @@ export const useThreadsStore = defineStore('threads', () => { threadId.value = null retainedThread.value = null openedThreads.value.clear() + fetchedThreadIds.value.clear() archivedThreads.value = false unreadOnly.value = false loadingThreads.value = true @@ -331,6 +357,7 @@ export const useThreadsStore = defineStore('threads', () => { hasThreadId, loadThreads, loadMoreThreads, + fetchThread, loadThreadMessages, setThreadId, toggleArchive, From 11ae74e4f71eb82c26a420ae91ad0cf49a75bab3 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 22:04:00 +0200 Subject: [PATCH 08/11] refactor: infer thread pagination from page size Remove has_more response metadata and internal extra-row look-ahead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/docs/docs.go | 8 +-- api/docs/swagger.json | 8 +-- api/docs/swagger.yaml | 8 +-- api/pkg/handlers/handler.go | 9 ---- api/pkg/handlers/message_thread_handler.go | 13 ++--- .../message_thread_handler_contacts_test.go | 31 ++++++----- api/pkg/responses/message_thead_responses.go | 2 - tests/unread_filter_integration_test.go | 51 ++++++++----------- web/app/stores/threads.test.ts | 29 ++++++++--- web/app/stores/threads.ts | 23 ++++----- web/shared/types/api.ts | 5 -- 11 files changed, 77 insertions(+), 110 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 3a4270193..04729b6e2 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1120,7 +1120,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. The has_more field indicates whether another page exists.", + "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", "consumes": [ "application/json" ], @@ -5608,7 +5608,6 @@ const docTemplate = `{ "type": "object", "required": [ "data", - "has_more", "message", "status" ], @@ -5619,11 +5618,6 @@ const docTemplate = `{ "$ref": "#/definitions/entities.MessageThread" } }, - "has_more": { - "description": "HasMore indicates that additional threads exist after this page.", - "type": "boolean", - "example": true - }, "message": { "type": "string", "example": "Request handled successfully" diff --git a/api/docs/swagger.json b/api/docs/swagger.json index b744dfa29..a24b2c520 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1117,7 +1117,7 @@ "ApiKeyAuth": [] } ], - "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. The has_more field indicates whether another page exists.", + "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.", "consumes": [ "application/json" ], @@ -5605,7 +5605,6 @@ "type": "object", "required": [ "data", - "has_more", "message", "status" ], @@ -5616,11 +5615,6 @@ "$ref": "#/definitions/entities.MessageThread" } }, - "has_more": { - "description": "HasMore indicates that additional threads exist after this page.", - "type": "boolean", - "example": true - }, "message": { "type": "string", "example": "Request handled successfully" diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 9b8b82ca4..838e36738 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1423,10 +1423,6 @@ definitions: items: $ref: '#/definitions/entities.MessageThread' type: array - has_more: - description: HasMore indicates that additional threads exist after this page. - example: true - type: boolean message: example: Request handled successfully type: string @@ -1435,7 +1431,6 @@ definitions: type: string required: - data - - has_more - message - status type: object @@ -2499,8 +2494,7 @@ paths: consumes: - application/json description: Get list of contacts which a phone number has communicated with - (threads). It will be sorted by timestamp in descending order. The has_more - field indicates whether another page exists. + (threads). It will be sorted by timestamp in descending order. parameters: - default: "+18005550199" description: owner phone number diff --git a/api/pkg/handlers/handler.go b/api/pkg/handlers/handler.go index 5640662d8..1f655b447 100644 --- a/api/pkg/handlers/handler.go +++ b/api/pkg/handlers/handler.go @@ -106,15 +106,6 @@ func (h *handler) responseOKWithTotal(c fiber.Ctx, message string, data interfac }) } -func (h *handler) responseOKWithHasMore(c fiber.Ctx, message string, data interface{}, hasMore bool) error { - return c.Status(fiber.StatusOK).JSON(fiber.Map{ - "status": "success", - "message": message, - "data": data, - "has_more": hasMore, - }) -} - func (h *handler) responseCreated(c fiber.Ctx, message string, data interface{}) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "status": "success", diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 161330109..30dfaf31a 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -49,7 +49,7 @@ func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares . // Index returns message threads for a phone number // @Summary Get message threads for a phone number -// @Description Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. The has_more field indicates whether another page exists. +// @Description Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order. // @Security ApiKeyAuth // @Tags MessageThreads // @Accept json @@ -85,20 +85,13 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while fetching message threads") } - params := request.ToGetParams(h.userIDFomContext(c)) - limit := params.Limit - params.Limit++ - threads, err := h.service.GetThreads(ctx, params) + threads, err := h.service.GetThreads(ctx, request.ToGetParams(h.userIDFomContext(c))) if err != nil { ctxLogger.Error(stacktrace.Propagatef(err, "cannot get message threads with params [%+#v]", request)) return h.responseInternalServerError(c) } - hasMore := len(*threads) > limit - if hasMore { - *threads = (*threads)[:limit] - } - return h.responseOKWithHasMore(c, fmt.Sprintf("fetched %d message %s", len(*threads), h.pluralize("thread", len(*threads))), threads, hasMore) + return h.responseOK(c, fmt.Sprintf("fetched %d message %s", len(*threads), h.pluralize("thread", len(*threads))), threads) } // Get returns a single conversation independently of list filters and pagination. diff --git a/api/pkg/handlers/message_thread_handler_contacts_test.go b/api/pkg/handlers/message_thread_handler_contacts_test.go index a6e41c5e7..d6c74770e 100644 --- a/api/pkg/handlers/message_thread_handler_contacts_test.go +++ b/api/pkg/handlers/message_thread_handler_contacts_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -32,6 +33,8 @@ func (stub *messageThreadHandlerIndexRepositoryStub) Index(_ context.Context, _ stub.limit = params.Limit threads := make([]entities.MessageThread, len(stub.threads)) copy(threads, stub.threads) + start := min(params.Skip, len(threads)) + threads = threads[start:min(start+params.Limit, len(threads))] return &threads, nil } @@ -100,17 +103,18 @@ func TestMessageThreadHandlerIndexRejectsInvalidUnreadFilter(t *testing.T) { assert.Equal(t, http.StatusUnprocessableEntity, response.StatusCode) } -func TestMessageThreadIndexReportsWhetherAnotherPageExists(t *testing.T) { +func TestMessageThreadIndexUsesRequestedLimitWithoutLookAhead(t *testing.T) { for _, test := range []struct { name string count int wantLen int - more bool + limit int }{ - {name: "empty", count: 0, wantLen: 0}, - {name: "partial page", count: 19, wantLen: 19}, - {name: "exactly one page", count: 20, wantLen: 20}, - {name: "another page exists", count: 21, wantLen: 20, more: true}, + {name: "empty", count: 0, wantLen: 0, limit: 20}, + {name: "partial page", count: 19, wantLen: 19, limit: 20}, + {name: "exactly one page", count: 20, wantLen: 20, limit: 20}, + {name: "another page exists", count: 21, wantLen: 20, limit: 20}, + {name: "custom limit", count: 4, wantLen: 3, limit: 3}, } { t.Run(test.name, func(t *testing.T) { repository := &messageThreadHandlerIndexRepositoryStub{threads: make([]entities.MessageThread, test.count)} @@ -125,19 +129,18 @@ func TestMessageThreadIndexReportsWhetherAnotherPageExists(t *testing.T) { }) handler.RegisterRoutes(app) - response, err := app.Test(httptest.NewRequest(http.MethodGet, "/v1/message-threads?owner=%2B18005550199&limit=20", nil)) + response, err := app.Test(httptest.NewRequest(http.MethodGet, fmt.Sprintf("/v1/message-threads?owner=%%2B18005550199&limit=%d", test.limit), nil)) require.NoError(t, err) defer response.Body.Close() require.Equal(t, http.StatusOK, response.StatusCode) - var body struct { - Data []entities.MessageThread `json:"data"` - HasMore bool `json:"has_more"` - } + var body map[string]json.RawMessage require.NoError(t, json.NewDecoder(response.Body).Decode(&body)) - assert.Len(t, body.Data, test.wantLen) - assert.Equal(t, test.more, body.HasMore) - assert.Equal(t, 21, repository.limit) + var threads []entities.MessageThread + require.NoError(t, json.Unmarshal(body["data"], &threads)) + assert.Len(t, threads, test.wantLen) + assert.NotContains(t, body, "has_more") + assert.Equal(t, test.limit, repository.limit) }) } } diff --git a/api/pkg/responses/message_thead_responses.go b/api/pkg/responses/message_thead_responses.go index 19e3e672b..e47fec287 100644 --- a/api/pkg/responses/message_thead_responses.go +++ b/api/pkg/responses/message_thead_responses.go @@ -6,8 +6,6 @@ import "github.com/NdoleStudio/httpsms/pkg/entities" type MessageThreadsResponse struct { response Data []entities.MessageThread `json:"data"` - // HasMore indicates that additional threads exist after this page. - HasMore bool `json:"has_more" example:"true"` } // MessageThreadResponse is the payload containing entities.MessageThread diff --git a/tests/unread_filter_integration_test.go b/tests/unread_filter_integration_test.go index 058bbf758..6366517b5 100644 --- a/tests/unread_filter_integration_test.go +++ b/tests/unread_filter_integration_test.go @@ -12,18 +12,8 @@ import ( "github.com/stretchr/testify/require" ) -type integrationThreadPage struct { - Data []integrationThread `json:"data"` - HasMore bool `json:"has_more"` -} - func fetchFilteredThreads(ctx context.Context, t *testing.T, owner string, archived, unreadOnly bool, limit, skip int) []integrationThread { t.Helper() - return fetchThreadPage(ctx, t, owner, archived, unreadOnly, limit, skip).Data -} - -func fetchThreadPage(ctx context.Context, t *testing.T, owner string, archived, unreadOnly bool, limit, skip int) integrationThreadPage { - t.Helper() query := url.Values{ "owner": {owner}, @@ -32,9 +22,11 @@ func fetchThreadPage(ctx context.Context, t *testing.T, owner string, archived, "limit": {fmt.Sprint(limit)}, "skip": {fmt.Sprint(skip)}, } - var response integrationThreadPage + var response struct { + Data []integrationThread `json:"data"` + } requestJSON(ctx, t, http.MethodGet, "/v1/message-threads?"+query.Encode(), userAPIKey, nil, http.StatusOK, &response) - return response + return response.Data } func TestMessageThreadUnreadFilter(t *testing.T) { @@ -97,7 +89,7 @@ func TestMessageThreadUnreadFilterRejectsInvalidValue(t *testing.T) { requestJSON(context.Background(), t, http.MethodGet, "/v1/message-threads?"+query.Encode(), userAPIKey, nil, http.StatusUnprocessableEntity, nil) } -func TestMessageThreadPaginationHasMore(t *testing.T) { +func TestMessageThreadPaginationReturnsRequestedPages(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) timestamp := time.Now().Add(-time.Minute).Truncate(time.Second) @@ -105,45 +97,44 @@ func TestMessageThreadPaginationHasMore(t *testing.T) { receiveInbound(ctx, t, phone.PhoneAPIKey, fmt.Sprintf("+1800554%04d", index), phone.PhoneNumber, "Pagination thread", timestamp) } - var first integrationThreadPage + var first []integrationThread deadline := time.Now().Add(20 * time.Second) for time.Now().Before(deadline) { - first = fetchThreadPage(ctx, t, phone.PhoneNumber, false, false, 20, 0) - if len(first.Data) == 20 { + first = fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, false, 20, 0) + if len(first) == 20 { break } time.Sleep(100 * time.Millisecond) } - require.Len(t, first.Data, 20) - assert.False(t, first.HasMore, "exactly 20 conversations must not offer Load More") + require.Len(t, first, 20) + assert.Empty(t, fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, false, 20, 20)) receiveInbound(ctx, t, phone.PhoneAPIKey, "+18005540020", phone.PhoneNumber, "One extra thread", timestamp) + var second []integrationThread deadline = time.Now().Add(20 * time.Second) for time.Now().Before(deadline) { - first = fetchThreadPage(ctx, t, phone.PhoneNumber, false, false, 20, 0) - if first.HasMore { + second = fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, false, 20, 20) + if len(second) == 1 { break } time.Sleep(100 * time.Millisecond) } - require.True(t, first.HasMore) - require.Len(t, first.Data, 20) - - second := fetchThreadPage(ctx, t, phone.PhoneNumber, false, false, 20, 20) - require.Len(t, second.Data, 1) - assert.False(t, second.HasMore) + require.Len(t, second, 1) + first = fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, false, 20, 0) + require.Len(t, first, 20) + assert.Empty(t, fetchFilteredThreads(ctx, t, phone.PhoneNumber, false, false, 20, 40)) seen := map[string]bool{} - for _, thread := range first.Data { + for _, thread := range first { seen[thread.ID] = true } - assert.False(t, seen[second.Data[0].ID], "equal timestamps must not duplicate threads between pages") + assert.False(t, seen[second[0].ID], "equal timestamps must not duplicate threads between pages") var individual struct { Data integrationThread `json:"data"` } - path := "/v1/message-threads/" + second.Data[0].ID + path := "/v1/message-threads/" + second[0].ID requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &individual) - assert.Equal(t, second.Data[0].ID, individual.Data.ID, "an older conversation must load independently of the first page") + assert.Equal(t, second[0].ID, individual.Data.ID, "an older conversation must load independently of the first page") assert.Equal(t, phone.PhoneNumber, individual.Data.Owner) archiveThread(ctx, t, individual.Data.ID) requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &individual) diff --git a/web/app/stores/threads.test.ts b/web/app/stores/threads.test.ts index 817a78beb..2580efd8f 100644 --- a/web/app/stores/threads.test.ts +++ b/web/app/stores/threads.test.ts @@ -72,12 +72,11 @@ function setupStore() { } const pending = nextResponse nextResponse = undefined - if (pending) return { ...(await pending), has_more: false } + if (pending) return pending const skip = Number(options.params?.skip ?? 0) const limit = Number(options.params?.limit ?? 20) return { data: responseThreads.slice(skip, skip + limit), - has_more: responseThreads.length > skip + limit, } }, }), @@ -372,14 +371,14 @@ function manyThreads(count: number) { } for (const count of [0, 19, 20, 21]) { - test(`Load More is available only when more than 20 threads exist (${count})`, async () => { + test(`Load More is available when the last page is full (${count})`, async () => { const { store, respondWith } = setupStore() respondWith(manyThreads(count)) await store.loadThreads() assert.equal(store.threads.length, Math.min(count, 20)) - assert.equal(store.hasMoreThreads, count > 20) + assert.equal(store.hasMoreThreads, count >= 20) }) } @@ -401,6 +400,23 @@ test('Load More expands the loaded list and disappears on the final page', async assert.equal(calls.length, completedCalls) }) +for (const count of [20, 40]) { + test(`an empty final page hides Load More without losing ${count} loaded threads`, async () => { + const { store, respondWith } = setupStore() + respondWith(manyThreads(count)) + await store.loadThreads() + if (count === 40) await store.loadMoreThreads() + assert.equal(store.threads.length, count) + assert.equal(store.hasMoreThreads, true) + + await store.loadMoreThreads() + + assert.equal(store.threads.length, count) + assert.equal(store.hasMoreThreads, false) + assert.equal(store.loadingMoreThreads, false) + }) +} + test('polling preserves loaded pages and changing filters resets them', async () => { const { store, respondWith } = setupStore() respondWith(manyThreads(45)) @@ -490,6 +506,7 @@ test('changing filters cancels an older Load More result', async () => { deferNextLoad(pending.promise) const loading = store.loadMoreThreads() + assert.equal(store.loadingMoreThreads, true) store.unreadOnly = true await store.loadThreads() pending.resolve({ data: existing.slice(0, 20) }) @@ -539,7 +556,7 @@ test('individual lookup preserves a real 404 instead of searching every page', a test('a successfully fetched conversation remains selectable independently of list metadata', async () => { const { store, respondWith, respondToLookup } = setupStore() - respondWith(manyThreads(20)) + respondWith(manyThreads(19)) const requested = thread('directly-requested', 0) respondToLookup(requested) await store.loadThreads() @@ -549,7 +566,7 @@ test('a successfully fetched conversation remains selectable independently of li store.setThreadId(requested.id) assert.equal(store.currentThread?.id, requested.id) - assert.equal(store.threads.length, 20) + assert.equal(store.threads.length, 19) }) test('resetting the session cancels an older individual lookup', async () => { diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 7d5be53e5..1ea3c4a90 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -126,19 +126,16 @@ export const useThreadsStore = defineStore('threads', () => { try { const responses = await Promise.all( Array.from({ length: pageCount }, (_, page) => - apiFetch<{ data: EntitiesMessageThread[]; has_more: boolean }>( - '/v1/message-threads', - { - params: { - owner, - limit: PAGE_SIZE, - skip: page * PAGE_SIZE, - is_archived: archived, - unread_only: unread, - contacts: true, - }, + apiFetch<{ data: EntitiesMessageThread[] }>('/v1/message-threads', { + params: { + owner, + limit: PAGE_SIZE, + skip: page * PAGE_SIZE, + is_archived: archived, + unread_only: unread, + contacts: true, }, - ), + }), ), ) if (requestId !== threadRequestId) return @@ -152,7 +149,7 @@ export const useThreadsStore = defineStore('threads', () => { const combined = new Map() let hasMore = false for (const response of responses) { - hasMore = response.has_more + hasMore = response.data.length === PAGE_SIZE for (const thread of response.data) { if (!combined.has(thread.id)) combined.set(thread.id, thread) } diff --git a/web/shared/types/api.ts b/web/shared/types/api.ts index a01eeaecb..9ecb82efb 100644 --- a/web/shared/types/api.ts +++ b/web/shared/types/api.ts @@ -784,11 +784,6 @@ export interface ResponsesMessageThreadResponse { export interface ResponsesMessageThreadsResponse { data: EntitiesMessageThread[]; - /** - * HasMore indicates that additional threads exist after this page. - * @example true - */ - has_more: boolean; /** @example "Request handled successfully" */ message: string; /** @example "success" */ From 7d02d63c7725ab7c599832cca1691ab60c3f1dfe Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 22:04:08 +0200 Subject: [PATCH 09/11] refactor: rename send attempt updater to IncreaseSendAttemptCount Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/pkg/repositories/gorm_message_repository.go | 2 +- api/pkg/repositories/gorm_message_repository_test.go | 2 +- api/pkg/repositories/message_repository.go | 4 ++-- api/pkg/services/message_service.go | 2 +- api/pkg/services/message_service_test.go | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go index 32cfdfe35..b6fdf9586 100644 --- a/api/pkg/repositories/gorm_message_repository.go +++ b/api/pkg/repositories/gorm_message_repository.go @@ -242,7 +242,7 @@ func (repository *gormMessageRepository) Update(ctx context.Context, message *en return nil } -func (repository *gormMessageRepository) IncrementSendAttemptCount(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error { +func (repository *gormMessageRepository) IncreaseSendAttemptCount(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error { ctx, span := repository.tracer.Start(ctx) defer span.End() diff --git a/api/pkg/repositories/gorm_message_repository_test.go b/api/pkg/repositories/gorm_message_repository_test.go index c5001f7b0..11dc28b7e 100644 --- a/api/pkg/repositories/gorm_message_repository_test.go +++ b/api/pkg/repositories/gorm_message_repository_test.go @@ -43,7 +43,7 @@ func TestMessageSendAttemptIncrementOnlyUpdatesItsOwnedColumn(t *testing.T) { logger := &messageThreadTestLogger{} repository := NewGormMessageRepository(logger, telemetry.NewOtelLogger("test", logger), db) - err = repository.IncrementSendAttemptCount(context.Background(), entities.UserID("user-id"), uuid.New()) + err = repository.IncreaseSendAttemptCount(context.Background(), entities.UserID("user-id"), uuid.New()) require.NoError(t, err) require.Len(t, pool.statements, 1) diff --git a/api/pkg/repositories/message_repository.go b/api/pkg/repositories/message_repository.go index bad3c82d7..dd069d284 100644 --- a/api/pkg/repositories/message_repository.go +++ b/api/pkg/repositories/message_repository.go @@ -15,8 +15,8 @@ type MessageRepository interface { // Update a new entities.Message Update(ctx context.Context, message *entities.Message) error - // IncrementSendAttemptCount atomically increments attempts without changing message status. - IncrementSendAttemptCount(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error + // IncreaseSendAttemptCount atomically increments attempts without changing message status. + IncreaseSendAttemptCount(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error // Load an entities.Message by ID Load(ctx context.Context, userID entities.UserID, messageID uuid.UUID) (*entities.Message, error) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 57171d4a2..75267be77 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -800,7 +800,7 @@ func (service *MessageService) HandleMessageNotificationSent(ctx context.Context ctxLogger := service.tracer.CtxLogger(service.logger, span) - if err := service.repository.IncrementSendAttemptCount(ctx, params.UserID, params.ID); err != nil { + if err := service.repository.IncreaseSendAttemptCount(ctx, params.UserID, params.ID); err != nil { return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot increment send attempts for message with id [%s]", params.ID)) } diff --git a/api/pkg/services/message_service_test.go b/api/pkg/services/message_service_test.go index 5a3790366..be17a3a14 100644 --- a/api/pkg/services/message_service_test.go +++ b/api/pkg/services/message_service_test.go @@ -31,7 +31,7 @@ func (repository *notificationMessageRepository) Update(_ context.Context, messa return nil } -func (repository *notificationMessageRepository) IncrementSendAttemptCount(context.Context, entities.UserID, uuid.UUID) error { +func (repository *notificationMessageRepository) IncreaseSendAttemptCount(context.Context, entities.UserID, uuid.UUID) error { repository.stored.SendAttemptCount++ return nil } From b59db35c817f589ad4f816b34f88ae5ab436d8ee Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 22:04:12 +0200 Subject: [PATCH 10/11] fix(api): default threads to unarchived on phone lookup errors Log the phone lookup failure and continue saving the unarchived thread. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/pkg/services/message_thread_service.go | 5 +++-- .../services/message_thread_service_test.go | 18 +++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index 506ae56e9..75488149a 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -213,9 +213,10 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me if params.Status != entities.MessageStatusReceived { phone, err := service.phoneRepository.Load(ctx, params.UserID, params.Owner) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load phone [%s] for user [%s] to resolve ArchiveNewOutgoingMessageThreads", params.Owner, params.UserID)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot load phone [%s] for user [%s] to resolve ArchiveNewOutgoingMessageThreads; creating the thread unarchived", params.Owner, params.UserID)) + } else { + isArchived = phone.ArchiveNewOutgoingMessageThreads } - isArchived = phone.ArchiveNewOutgoingMessageThreads } unreadCount := uint(0) diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go index 2f290fa71..eaa487296 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -299,17 +299,19 @@ func TestCreateThreadArchivesOnlyNewOutgoingConversationsForEnabledPhone(t *test } } -func TestCreateOutgoingThreadDoesNotStoreWhenPhoneCannotBeLoaded(t *testing.T) { - stored := false +func TestCreateOutgoingThreadDefaultsToUnarchivedWhenPhoneCannotBeLoaded(t *testing.T) { + var stored *entities.MessageThread service := newMessageThreadServiceForTest(&messageThreadRepositoryStub{ loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { return nil, stacktrace.PropagateWithCodef(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") }, - store: func(context.Context, *entities.MessageThread) error { - stored = true + store: func(_ context.Context, thread *entities.MessageThread) error { + stored = thread return nil }, }) + logger := &phoneNotificationLogger{} + service.logger = logger phoneErr := stacktrace.NewError("phone unavailable") service.phoneRepository = &threadPhoneRepositoryStub{ load: func(context.Context, entities.UserID, string) (*entities.Phone, error) { @@ -323,9 +325,11 @@ func TestCreateOutgoingThreadDoesNotStoreWhenPhoneCannotBeLoaded(t *testing.T) { Status: entities.MessageStatusPending, }) - require.Error(t, err) - assert.Contains(t, err.Error(), "phone unavailable") - assert.False(t, stored) + require.NoError(t, err) + require.NotNil(t, stored) + assert.False(t, stored.IsArchived) + require.Len(t, logger.warnings, 1) + assert.Contains(t, logger.warnings[0], "phone unavailable") } func TestArchiveNewOutgoingMessageThreadsPreservesExistingConversationBehavior(t *testing.T) { From 9a49b55c758a9141866ce96620cca85b18cdf6ee Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 27 Oct 2026 22:06:39 +0200 Subject: [PATCH 11/11] refactor: apply local thread lookup edits Remove the phone-specific read check and adjust cancellation wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aad7909a-5c34-42df-a4d2-ae6c83c22e7b --- api/pkg/handlers/message_thread_handler.go | 5 +---- web/app/stores/threads.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 30dfaf31a..0225ee894 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -94,7 +94,7 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { return h.responseOK(c, fmt.Sprintf("fetched %d message %s", len(*threads), h.pluralize("thread", len(*threads))), threads) } -// Get returns a single conversation independently of list filters and pagination. +// Get returns a single entities.MessageThread independently of list filters and pagination. // @Summary Get a message thread // @Description Returns a conversation belonging to the authenticated user, including matching contact details when available. // @Security ApiKeyAuth @@ -124,9 +124,6 @@ func (h *MessageThreadHandler) Get(c fiber.Ctx) error { ctxLogger.Error(stacktrace.Propagatef(err, "cannot fetch message thread [%s]", id)) return h.responseInternalServerError(c) } - if !h.authorizePhoneAPIKey(c, thread.Owner) { - return h.responsePhoneAPIKeyUnauthorized(c, thread.Owner, h.userFromContext(c)) - } return h.responseOK(c, "message thread fetched successfully", thread) } diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 1ea3c4a90..77f2ccef0 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -197,7 +197,7 @@ export const useThreadsStore = defineStore('threads', () => { `/v1/message-threads/${id}`, ) if (generation !== stateGeneration) { - throw new DOMException('Conversation lookup was canceled', 'AbortError') + throw new DOMException('thread fetch was canceled', 'AbortError') } openedThreads.value.set(response.data.id, response.data) fetchedThreadIds.value.add(response.data.id)