From 02178ba177ab479109344caf062c4d61fad911b6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 09:55:00 +0300 Subject: [PATCH 01/22] docs: add read receipts design Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../specs/2026-07-18-read-receipts-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-18-read-receipts-design.md diff --git a/docs/superpowers/specs/2026-07-18-read-receipts-design.md b/docs/superpowers/specs/2026-07-18-read-receipts-design.md new file mode 100644 index 000000000..d632755f1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-read-receipts-design.md @@ -0,0 +1,269 @@ +# Message Thread Read Receipts + +- Date: 2026-07-18 +- Status: Approved (design) +- Scope: `api/` Go backend and `web/` Nuxt frontend. Android is unchanged. +- Branch: `feat/read-receipts`, based on `origin/main` + +## Problem + +The message-thread list does not distinguish conversations with unseen inbound +activity from conversations the user has already opened. Users need unread +threads to stand out and become read automatically when opened. + +The change must be backward compatible: every thread that exists when the new +schema is deployed starts as read. + +## Decisions + +- Unread state is stored per message thread. +- Existing threads are read after migration. +- A new incoming SMS or missed call marks its thread unread. +- Outbound messages and later delivery/status updates do not change read state. +- Opening a thread in the web UI marks it read automatically. +- A thread that receives inbound activity while already open remains read. +- There is no manual "Mark as read/unread" UI. +- Unread threads use bold contact/preview text and a subtle primary-tinted + background. +- The existing `PUT /v1/message-threads/{messageThreadID}` endpoint handles the + read update; no new endpoint is added. + +## Design + +### 1. Thread persistence + +Add these fields to `api/pkg/entities/message_thread.go`: + +```go +IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"` +LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"` +``` + +`IsRead` is the public binary state used by API clients and the web UI. +`LastReadAt` is internal ordering metadata used to make concurrent event +processing deterministic. + +GORM `AutoMigrate` adds both columns. The database defaults make all existing +rows read and give them a migration-time read watermark. New-thread creation +sets `IsRead` explicitly according to whether the triggering activity is +inbound. + +Existing-row updates must not use the repository's current full-row `Save` +method. A stale entity loaded by one event handler could otherwise overwrite a +newer read action from the web request. + +### 2. Atomic repository updates + +Add field-scoped repository operations for the two update paths: + +- **Message activity update:** update only `order_timestamp`, + `last_message_id`, `last_message_content`, and `status`. For inbound activity, + conditionally set `is_read = false` only where the stored `last_read_at` is + older than the CloudEvent creation time. +- **User status update:** update only the supplied `is_archived` and `is_read` + fields. Marking read writes `is_read = true` and `last_read_at = now` in the + same database update. + +The message activity update runs its metadata and conditional unread statements +inside one GORM transaction. Both operations remain scoped by `user_id` and +thread ID and use GORM's query builder with context propagation. + +After a user status update, reload the thread for the API response. Do not use +`Save` in either path, because it writes unrelated columns and reintroduces lost +updates. + +The stored timestamp condition prevents this race: + +1. An inbound event is created. +2. The websocket listener notifies the open web page. +3. The web page marks the thread read. +4. The message-thread listener finishes later. + +Because CloudEvent creation time precedes the read request, the late listener +checks the newer database watermark and does not overwrite the read action. If +the message listener commits first, the later read update wins normally. + +### 3. Thread update parameters + +Extend `services.MessageThreadUpdateParams` with: + +```go +MarksUnread bool +EventTimestamp time.Time +``` + +Inbound SMS and missed-call listeners set `MarksUnread = true` and use +`event.Time()` as `EventTimestamp`. Other message events leave `MarksUnread` +false, so sending, scheduling, delivery, failure, and expiry updates preserve +the current read state. + +For an existing thread, the service delegates the field-scoped message update +and optional conditional unread transition to the repository. + +For a newly created thread: + +- inbound SMS or missed call: `IsRead = false`; +- outbound activity: `IsRead = true`. + +### 4. Missed-call thread updates + +`MessageThreadListener` currently handles received SMS events but not +`message.call.missed`. Register the missed-call event and update/create the +corresponding thread with: + +- owner and contact from `MessageCallMissedPayload`; +- message ID and event timestamp; +- received status; +- a concise preview such as `Missed phone call`; +- `MarksUnread = true`. + +This ensures a missed call is visible as thread activity even when no auto-reply +is configured. + +### 5. Existing update endpoint + +Change `requests.MessageThreadUpdate` to use optional fields: + +```go +IsArchived *bool `json:"is_archived,omitempty" example:"true"` +IsRead *bool `json:"is_read,omitempty" example:"true"` +``` + +The validator requires at least one supported field. Pointer fields distinguish +"not supplied" from `false`, preventing a read-only update from unarchiving a +thread and an archive-only update from changing read state. + +`MessageThreadStatusParams` receives the optional fields. `UpdateStatus`: + +- loads the authenticated user's thread; +- builds an update map containing only supplied fields; +- writes `last_read_at = time.Now().UTC()` alongside `is_read = true`; +- supports `IsRead = false` at the API level without adding a web control; +- persists one combined partial update and reloads the updated thread. + +The handler preserves repository error codes so a missing thread returns 404. +Invalid IDs or empty/unsupported update bodies return validation errors. + +### 6. Web store behavior + +Regenerate the API model so `EntitiesMessageThread` includes `is_read` and +`RequestsMessageThreadUpdate` has optional `is_archived` and `is_read`. + +In `web/app/stores/threads.ts`: + +- keep archive updates on the existing endpoint with + `{ is_archived: value }`; +- add `markThreadRead(threadId)` using `{ is_read: true }`; +- replace the matching thread in local state with the successful API response; +- make the operation idempotent and skip the request when the local thread is + already read unless it is called after an inbound realtime event. + +Opening a thread invokes `markThreadRead` automatically as part of the thread +loading flow. Message fetching remains independent: if the read update fails, +messages still display, the thread remains/reloads as unread, and the existing +notification system reports the failure. + +### 7. Realtime behavior + +The message detail page already reloads messages for incoming SMS websocket +events. That reload also marks the currently selected thread read. + +Add `message.call.missed` to: + +- `WebsocketListener` registrations in the API; +- the Pusher bindings on the thread detail page. + +The websocket payload can remain the event ID. The selected thread is marked +read idempotently after any inbound realtime refresh; a different thread's +backend state remains unread. + +### 8. Thread-list presentation + +In `web/app/components/MessageThread.vue`, unread list items receive: + +- bold contact title; +- bold message preview; +- a subtle background using the Vuetify primary theme color at low opacity. + +Read threads retain the current appearance. Active-route styling remains +visible and takes precedence where Vuetify applies it. + +The highlight is applied in the shared thread-list component, so it works in +both the mobile thread page and desktop navigation drawer, including archived +threads. + +### 9. Swagger and generated types + +After changing the entity and request annotations: + +1. Run `swag init --requiredByDefault --parseDependency --parseInternal` in + `api/`. +2. Run `pnpm api:models` in `web/`. + +Generated Swagger files and `web/shared/types/api.ts` are committed with the +implementation. + +## Error Handling + +- Repository and service errors continue to use `stacktrace.Propagate`. +- The update handler returns 404 for a thread that does not belong to the + authenticated user. +- A failed automatic read update is not swallowed and does not block message + display. +- Local UI state changes only from a successful response or a subsequent thread + reload; the UI does not claim a persisted read state after an API failure. +- Realtime callbacks report failures through the existing notification/error + path rather than using empty catches. + +## Testing + +### API + +Add targeted tests for: + +- entity schema defaults and new-thread read-state initialization; +- an inbound SMS making an existing thread unread; +- a missed call creating or updating an unread thread; +- outbound and delivery/status events preserving read state; +- a read action winning when its timestamp is newer than an inbound event; +- concurrent message/read updates changing only their owned columns; +- archive-only updates preserving read state; +- read-only updates preserving archive state; +- missing threads preserving the not-found error code; +- existing-row migration defaults (`is_read = true`). + +Run: + +```bash +cd api +go test ./... +``` + +### Web + +No frontend unit-test runner is configured. Validate the generated types, +Pinia/Vue changes, styles, and production output with: + +```bash +cd web +pnpm lint +pnpm run generate +``` + +Manual acceptance checks: + +1. Existing threads appear read immediately after migration. +2. A new incoming SMS highlights its closed thread. +3. A missed call highlights its closed thread. +4. Opening an unread thread removes the highlight and persists across refresh. +5. Incoming activity in the currently open thread does not leave it unread. +6. Sending or receiving delivery updates does not clear another unread state. +7. Archiving/unarchiving does not change read state. + +## Out of Scope + +- Per-message read receipts. +- Android read/unread UI. +- Unread counts or badges outside the thread list. +- Manual read/unread controls. +- Cross-user/group-conversation receipt tracking. From f8f08007bbe5ebf52d12262473a7dd8713a754f3 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:07:36 +0300 Subject: [PATCH 02/22] docs: add read receipts implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../plans/2026-07-18-read-receipts.md | 1472 +++++++++++++++++ 1 file changed, 1472 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-read-receipts.md diff --git a/docs/superpowers/plans/2026-07-18-read-receipts.md b/docs/superpowers/plans/2026-07-18-read-receipts.md new file mode 100644 index 000000000..0972a0129 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-read-receipts.md @@ -0,0 +1,1472 @@ +# Message Thread Read Receipts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist message-thread read state, mark inbound SMS and missed calls unread, automatically mark opened threads read, and highlight unread threads in the web UI. + +**Architecture:** Store `is_read` plus an internal `last_read_at` watermark on each message thread. Replace full-row thread saves with field-scoped GORM updates so concurrent event listeners cannot overwrite a newer read action; inbound updates compare CloudEvent creation time against the stored watermark. Extend the existing thread update endpoint with optional archive/read fields, then have the Nuxt store call it automatically when the thread page opens or refreshes after inbound realtime events. + +**Tech Stack:** Go 1.25, Fiber v3, GORM/PostgreSQL, CloudEvents, Pusher, Nuxt 4 SPA, Vue 3, Pinia, Vuetify 4, TypeScript. + +## Global Constraints + +- Work only in `C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts` on branch `feat/read-receipts`, based on `origin/main`. +- Existing message threads must migrate as read. +- Only incoming SMS and missed-call activity marks a thread unread. +- Outbound messages and delivery/status events preserve the existing read state. +- Opening a thread marks it read automatically; inbound activity while it is open must leave it read. +- Do not add a new endpoint or a manual read/unread control. +- Use the existing `PUT /v1/message-threads/{messageThreadID}` endpoint with optional `is_archived` and `is_read`. +- Use GORM query builders with `WithContext(ctx)`; do not use raw SQL. +- Wrap API errors with `stacktrace.Propagate`/`PropagateWithCode`. +- Web code uses single quotes, no semicolons, and 2-space indentation. +- Every commit must end with: + +```text +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +``` + +## File Structure + +- `api/pkg/entities/message_thread.go`: persisted read fields. +- `api/pkg/entities/message_thread_test.go`: schema-default regression tests. +- `api/pkg/repositories/message_thread_repository.go`: field-scoped update parameter types and interface methods. +- `api/pkg/repositories/gorm_message_thread_repository.go`: transactional activity updates, conditional unread transition, partial user-status update, and partial deleted-message update. +- `api/pkg/repositories/gorm_message_thread_repository_test.go`: update-map ownership tests. +- `api/pkg/services/message_thread_service.go`: read-state business rules and repository orchestration. +- `api/pkg/services/message_thread_service_test.go`: service behavior with a repository stub. +- `api/pkg/requests/message_thread_update_request.go`: optional archive/read request fields. +- `api/pkg/requests/message_thread_update_request_test.go`: request-to-service conversion tests. +- `api/pkg/validators/message_thread_handler_validator.go`: require at least one update field. +- `api/pkg/validators/message_thread_handler_validator_test.go`: empty-payload and valid-payload tests. +- `api/pkg/responses/message_thead_responses.go`: single-thread Swagger response. +- `api/pkg/handlers/message_thread_handler.go`: not-found mapping and corrected Swagger response. +- `api/pkg/listeners/message_thread_listener.go`: incoming SMS and missed-call unread updates. +- `api/pkg/listeners/websocket_listener.go`: missed-call Pusher publication. +- `api/pkg/listeners/read_receipts_test_helpers_test.go`: listener logger and repository test doubles. +- `api/pkg/listeners/message_thread_listener_test.go`: listener route and payload tests. +- `api/pkg/listeners/websocket_listener_test.go`: missed-call route registration test. +- `api/docs/docs.go`, `api/docs/swagger.json`, `api/docs/swagger.yaml`: regenerated API documentation. +- `web/shared/types/api.ts`: regenerated thread/request types. +- `web/app/stores/threads.ts`: automatic read update and local state replacement. +- `web/app/pages/threads/[id]/index.vue`: non-blocking read calls and missed-call realtime refresh. +- `web/app/components/MessageThread.vue`: unread visual treatment. + +--- + +### Task 1: Add the read-state API contract + +**Files:** +- Modify: `api/pkg/entities/message_thread.go` +- Create: `api/pkg/entities/message_thread_test.go` +- Modify: `api/pkg/requests/message_thread_update_request.go` +- Create: `api/pkg/requests/message_thread_update_request_test.go` +- Modify: `api/pkg/validators/message_thread_handler_validator.go` +- Create: `api/pkg/validators/message_thread_handler_validator_test.go` +- Modify: `api/pkg/responses/message_thead_responses.go` +- Modify: `api/pkg/services/message_thread_service.go` + +**Interfaces:** +- Produces: `MessageThread.IsRead bool`, `MessageThread.LastReadAt time.Time` +- Produces: `MessageThreadUpdate.IsArchived *bool`, `MessageThreadUpdate.IsRead *bool` +- Produces: `MessageThreadStatusParams.IsArchived *bool`, `MessageThreadStatusParams.IsRead *bool` +- Produces: `responses.MessageThreadResponse` + +- [ ] **Step 1: Write failing entity schema tests** + +Create `api/pkg/entities/message_thread_test.go`: + +```go +package entities + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + isRead, ok := threadType.FieldByName("IsRead") + require.True(t, ok) + assert.Contains(t, isRead.Tag.Get("gorm"), "not null") + assert.Contains(t, isRead.Tag.Get("gorm"), "default:true") + assert.Equal(t, "is_read", isRead.Tag.Get("json")) + + lastReadAt, ok := threadType.FieldByName("LastReadAt") + require.True(t, ok) + assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null") + assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP") + assert.Equal(t, "-", lastReadAt.Tag.Get("json")) +} +``` + +- [ ] **Step 2: Write failing request and validator tests** + +Create `api/pkg/requests/message_thread_update_request_test.go`: + +```go +package requests + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) { + threadID := uuid.New() + isRead := true + input := MessageThreadUpdate{ + MessageThreadID: threadID.String(), + IsRead: &isRead, + } + + params := input.ToUpdateParams(entities.UserID("user-id")) + + assert.Equal(t, threadID, params.MessageThreadID) + assert.Equal(t, entities.UserID("user-id"), params.UserID) + assert.Nil(t, params.IsArchived) + assert.Same(t, &isRead, params.IsRead) +} +``` + +Create `api/pkg/validators/message_thread_handler_validator_test.go`: + +```go +package validators + +import ( + "context" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestValidateUpdateRequiresAtLeastOneStatusField(t *testing.T) { + validator := &MessageThreadHandlerValidator{} + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + } + + errors := validator.ValidateUpdate(context.Background(), request) + + assert.NotEmpty(t, errors.Get("payload")) +} + +func TestValidateUpdateAcceptsReadOnlyUpdate(t *testing.T) { + validator := &MessageThreadHandlerValidator{} + isRead := true + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + IsRead: &isRead, + } + + errors := validator.ValidateUpdate(context.Background(), request) + + assert.Empty(t, errors) +} +``` + +- [ ] **Step 3: Run the focused tests and confirm they fail** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\api' +go test ./pkg/entities ./pkg/requests ./pkg/validators +``` + +Expected: compile failures because the read fields and optional request fields do not exist. + +- [ ] **Step 4: Add the persisted fields and optional request contract** + +Add to `entities.MessageThread` after `IsArchived`: + +```go +IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"` +LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"` +``` + +Change `requests.MessageThreadUpdate` and its conversion: + +```go +type MessageThreadUpdate struct { + request + IsArchived *bool `json:"is_archived,omitempty" example:"true"` + IsRead *bool `json:"is_read,omitempty" example:"true"` + + MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` +} + +func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) services.MessageThreadStatusParams { + return services.MessageThreadStatusParams{ + UserID: userID, + MessageThreadID: uuid.MustParse(input.MessageThreadID), + IsArchived: input.IsArchived, + IsRead: input.IsRead, + } +} +``` + +Change the service parameter type: + +```go +type MessageThreadStatusParams struct { + IsArchived *bool + IsRead *bool + UserID entities.UserID + MessageThreadID uuid.UUID +} +``` + +After `v.ValidateStruct()` in `ValidateUpdate`, add an explicit payload check: + +```go +errors := v.ValidateStruct() +if request.IsArchived == nil && request.IsRead == nil { + errors.Add("payload", "at least one of is_archived or is_read is required") +} +return errors +``` + +Add a single-thread response beside `MessageThreadsResponse`: + +```go +// MessageThreadResponse is the payload containing entities.MessageThread +type MessageThreadResponse struct { + response + Data entities.MessageThread `json:"data"` +} +``` + +- [ ] **Step 5: Run focused tests** + +Run: + +```powershell +go test ./pkg/entities ./pkg/requests ./pkg/validators +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the contract** + +```powershell +git add api/pkg/entities/message_thread.go api/pkg/entities/message_thread_test.go api/pkg/requests/message_thread_update_request.go api/pkg/requests/message_thread_update_request_test.go api/pkg/validators/message_thread_handler_validator.go api/pkg/validators/message_thread_handler_validator_test.go api/pkg/responses/message_thead_responses.go api/pkg/services/message_thread_service.go +@' +feat(api): add thread read state contract + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 2: Add atomic thread persistence operations + +**Files:** +- Modify: `api/pkg/repositories/message_thread_repository.go` +- Modify: `api/pkg/repositories/gorm_message_thread_repository.go` +- Create: `api/pkg/repositories/gorm_message_thread_repository_test.go` + +**Interfaces:** +- Produces: `repositories.MessageThreadActivityUpdate` +- Produces: `repositories.MessageThreadStatusUpdate` +- Produces: `repositories.MessageThreadDeletedUpdate` +- Produces: `MessageThreadRepository.UpdateActivity`, `UpdateStatus`, `UpdateAfterDeletedMessage` +- Removes: `MessageThreadRepository.Update` + +- [ ] **Step 1: Write failing update-map ownership tests** + +Create `api/pkg/repositories/gorm_message_thread_repository_test.go`: + +```go +package repositories + +import ( + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { + messageID := uuid.New() + updates := messageThreadActivityUpdates(MessageThreadActivityUpdate{ + Timestamp: time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + MessageID: messageID, + Content: "hello", + Status: entities.MessageStatusReceived, + }) + + assert.Equal(t, map[string]any{ + "order_timestamp": time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + "last_message_id": messageID, + "last_message_content": "hello", + "status": entities.MessageStatusReceived, + }, updates) + assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "is_archived") + assert.NotContains(t, updates, "last_read_at") +} + +func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) { + isRead := true + readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC) + + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsRead: &isRead, + ReadAt: readAt, + }) + + assert.Equal(t, map[string]any{ + "is_read": true, + "last_read_at": readAt, + }, updates) + assert.NotContains(t, updates, "is_archived") +} + +func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) { + isArchived := true + + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsArchived: &isArchived, + }) + + assert.Equal(t, map[string]any{"is_archived": true}, updates) + assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "last_read_at") +} +``` + +- [ ] **Step 2: Run the repository test and confirm it fails** + +Run: + +```powershell +go test ./pkg/repositories -run MessageThread -v +``` + +Expected: compile failures because the parameter types and helper functions do not exist. + +- [ ] **Step 3: Define repository update parameters and methods** + +In `message_thread_repository.go`, add: + +```go +type MessageThreadActivityUpdate struct { + MessageThreadID uuid.UUID + UserID entities.UserID + Timestamp time.Time + MessageID uuid.UUID + Content string + Status entities.MessageStatus + MarksUnread bool + EventTimestamp time.Time +} + +type MessageThreadStatusUpdate struct { + IsArchived *bool + IsRead *bool + ReadAt time.Time +} + +type MessageThreadDeletedUpdate struct { + MessageThreadID uuid.UUID + UserID entities.UserID + LastMessageID *uuid.UUID + LastMessageContent *string + LastMessageStatus entities.MessageStatus +} +``` + +Add the `time` import. Replace `Update` and the old deleted-message method in the interface with: + +```go +UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error +UpdateStatus(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID, params MessageThreadStatusUpdate) error +UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error +``` + +- [ ] **Step 4: Implement field-scoped GORM updates** + +Add these helpers to `gorm_message_thread_repository.go`: + +```go +func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string]any { + return map[string]any{ + "order_timestamp": params.Timestamp, + "last_message_id": params.MessageID, + "last_message_content": params.Content, + "status": params.Status, + } +} + +func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any { + updates := make(map[string]any) + if params.IsArchived != nil { + updates["is_archived"] = *params.IsArchived + } + if params.IsRead != nil { + updates["is_read"] = *params.IsRead + if *params.IsRead { + updates["last_read_at"] = params.ReadAt + } + } + return updates +} +``` + +Replace the full-row `Update` implementation with: + +```go +func (repository *gormMessageThreadRepository) UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID) + + result := query.Updates(messageThreadActivityUpdates(params)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return stacktrace.PropagateWithCode( + gorm.ErrRecordNotFound, + ErrCodeNotFound, + fmt.Sprintf("thread with id [%s] not found", params.MessageThreadID), + ) + } + + if !params.MarksUnread { + return nil + } + + return tx.Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Where("last_read_at < ?", params.EventTimestamp). + Update("is_read", false). + Error + }) + if err != nil { + msg := fmt.Sprintf("cannot update message activity for thread [%s]", params.MessageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } + return nil +} + +func (repository *gormMessageThreadRepository) UpdateStatus( + ctx context.Context, + userID entities.UserID, + messageThreadID uuid.UUID, + params MessageThreadStatusUpdate, +) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", userID). + Where("id = ?", messageThreadID). + Updates(messageThreadStatusUpdates(params)) + if result.Error != nil { + msg := fmt.Sprintf("cannot update status for thread [%s]", messageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) + } + if result.RowsAffected == 0 { + msg := fmt.Sprintf("thread with id [%s] not found", messageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, msg)) + } + return nil +} + +func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Updates(map[string]any{ + "last_message_id": params.LastMessageID, + "last_message_content": params.LastMessageContent, + "status": params.LastMessageStatus, + }) + if result.Error != nil { + msg := fmt.Sprintf("cannot update deleted-message metadata for thread [%s]", params.MessageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) + } + return nil +} +``` + +Delete the old `Save(thread)` method and the old unused `UpdateAfterDeletedMessage(userID, messageID)` implementation. + +- [ ] **Step 5: Run repository tests** + +Run: + +```powershell +go test ./pkg/repositories -run MessageThread -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit atomic persistence** + +```powershell +git add api/pkg/repositories/message_thread_repository.go api/pkg/repositories/gorm_message_thread_repository.go api/pkg/repositories/gorm_message_thread_repository_test.go +@' +refactor(api): make thread updates atomic + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 3: Apply read rules in services and listeners + +**Files:** +- Modify: `api/pkg/services/message_thread_service.go` +- Create: `api/pkg/services/message_thread_service_test.go` +- Modify: `api/pkg/listeners/message_thread_listener.go` +- Create: `api/pkg/listeners/read_receipts_test_helpers_test.go` +- Create: `api/pkg/listeners/message_thread_listener_test.go` +- Modify: `api/pkg/listeners/websocket_listener.go` +- Create: `api/pkg/listeners/websocket_listener_test.go` + +**Interfaces:** +- Consumes: repository update types and methods from Task 2. +- Produces: `MessageThreadUpdateParams.MarksUnread bool` +- Produces: `MessageThreadUpdateParams.EventTimestamp time.Time` +- Produces: missed-call thread and websocket listeners. + +- [ ] **Step 1: Write failing service behavior tests** + +Create `api/pkg/services/message_thread_service_test.go`. Implement a repository stub with all interface methods returning zero values by default and function hooks for the methods under test: + +```go +package services + +import ( + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/palantir/stacktrace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +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) + store func(context.Context, *entities.MessageThread) error + updateActivity func(context.Context, repositories.MessageThreadActivityUpdate) error + updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error +} + +func (stub *messageThreadRepositoryStub) Store(ctx context.Context, thread *entities.MessageThread) error { + if stub.store != nil { + return stub.store(ctx, thread) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateActivity(ctx context.Context, params repositories.MessageThreadActivityUpdate) error { + if stub.updateActivity != nil { + return stub.updateActivity(ctx, params) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateStatus(ctx context.Context, userID entities.UserID, threadID uuid.UUID, params repositories.MessageThreadStatusUpdate) error { + if stub.updateStatus != nil { + return stub.updateStatus(ctx, userID, threadID, params) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (stub *messageThreadRepositoryStub) LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) { + return stub.loadByOwnerContact(ctx, userID, owner, contact) +} + +func (stub *messageThreadRepositoryStub) Load(ctx context.Context, userID entities.UserID, id uuid.UUID) (*entities.MessageThread, error) { + return stub.load(ctx, userID, id) +} + +func (stub *messageThreadRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := []entities.MessageThread{} + return &threads, nil +} + +func (stub *messageThreadRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (stub *messageThreadRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +func newMessageThreadServiceForTest(repository repositories.MessageThreadRepository) *MessageThreadService { + logger := &noopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewMessageThreadService(logger, tracer, repository, nil) +} +``` + +Add these tests below the stub: + +```go +func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { + threadID := uuid.New() + eventTimestamp := time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC) + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: eventTimestamp, + MarksUnread: true, + EventTimestamp: eventTimestamp, + }) + + require.NoError(t, err) + assert.True(t, captured.MarksUnread) + assert.Equal(t, eventTimestamp, captured.EventTimestamp) +} + +func TestUpdateThreadPreservesReadStateForOutboundActivity(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(), IsRead: false}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "outbound", + Status: entities.MessageStatusSent, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + assert.False(t, captured.MarksUnread) +} + +func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { + tests := []struct { + name string + marksUnread bool + wantRead bool + }{ + {name: "inbound", marksUnread: true, wantRead: false}, + {name: "outbound", marksUnread: false, wantRead: true}, + } + + 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.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + store: func(_ context.Context, thread *entities.MessageThread) error { + stored = thread + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: time.Now().UTC(), + MarksUnread: test.marksUnread, + }) + + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, test.wantRead, stored.IsRead) + assert.False(t, stored.LastReadAt.IsZero()) + }) + } +} + +func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { + threadID := uuid.New() + isRead := true + var captured repositories.MessageThreadStatusUpdate + repository := &messageThreadRepositoryStub{ + updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) error { + captured = params + return nil + }, + load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: true}, nil + }, + } + + service := newMessageThreadServiceForTest(repository) + thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: threadID, + IsRead: &isRead, + }) + + require.NoError(t, err) + assert.Nil(t, captured.IsArchived) + assert.Same(t, &isRead, captured.IsRead) + assert.False(t, captured.ReadAt.IsZero()) + assert.True(t, thread.IsArchived) +} + +func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { + repository := &messageThreadRepositoryStub{ + updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { + return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + } + + service := newMessageThreadServiceForTest(repository) + isRead := true + _, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: uuid.New(), + IsRead: &isRead, + }) + + assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) +} +``` + +- [ ] **Step 2: Run service tests and confirm they fail** + +Run: + +```powershell +go test ./pkg/services -run 'Test(UpdateThread|CreateThread|UpdateStatus)' -v +``` + +Expected: compile failures because the new update parameters and repository calls are not wired. + +- [ ] **Step 3: Implement service read rules** + +Extend `MessageThreadUpdateParams`: + +```go +MarksUnread bool +EventTimestamp time.Time +``` + +Replace the existing full-row update in `UpdateThread` with: + +```go +if err = service.repository.UpdateActivity(ctx, repositories.MessageThreadActivityUpdate{ + MessageThreadID: thread.ID, + UserID: params.UserID, + Timestamp: params.Timestamp, + MessageID: params.MessageID, + Content: params.Content, + Status: params.Status, + MarksUnread: params.MarksUnread, + EventTimestamp: params.EventTimestamp, +}); err != nil { + msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) +} +``` + +In `createThread`, create one `now := time.Now().UTC()` and initialize: + +```go +IsRead: !params.MarksUnread, +LastReadAt: now, +CreatedAt: now, +UpdatedAt: now, +``` + +Replace `UpdateStatus` with a partial repository update followed by a reload: + +```go +func (service *MessageThreadService) UpdateStatus(ctx context.Context, params MessageThreadStatusParams) (*entities.MessageThread, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + update := repositories.MessageThreadStatusUpdate{ + IsArchived: params.IsArchived, + IsRead: params.IsRead, + ReadAt: time.Now().UTC(), + } + if err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { + msg := fmt.Sprintf("cannot update message thread with id [%s]", params.MessageThreadID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } + + thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) + if err != nil { + msg := fmt.Sprintf("cannot reload message thread with id [%s]", params.MessageThreadID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } + return thread, nil +} +``` + +In `UpdateAfterDeletedMessage`, replace the full-row update with: + +```go +if err = service.repository.UpdateAfterDeletedMessage(ctx, repositories.MessageThreadDeletedUpdate{ + MessageThreadID: thread.ID, + UserID: thread.UserID, + LastMessageID: payload.PreviousMessageID, + LastMessageContent: payload.PreviousMessageContent, + LastMessageStatus: *payload.PreviousMessageStatus, +}); err != nil { + msg := fmt.Sprintf("cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) +} +``` + +- [ ] **Step 4: Add inbound and missed-call listener behavior** + +In `NewMessageThreadListener`, register: + +```go +events.MessageCallMissed: l.OnMessageCallMissed, +``` + +In `OnMessagePhoneReceived`, add: + +```go +MarksUnread: true, +EventTimestamp: event.Time(), +``` + +Add: + +```go +func (listener *MessageThreadListener) OnMessageCallMissed(ctx context.Context, event cloudevents.Event) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + params := services.MessageThreadUpdateParams{ + Owner: payload.Owner, + Contact: payload.Contact, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Timestamp: payload.Timestamp, + Content: "Missed phone call", + MessageID: payload.MessageID, + MarksUnread: true, + EventTimestamp: event.Time(), + } + if err := listener.service.UpdateThread(ctx, params); err != nil { + msg := fmt.Sprintf("cannot update thread for missed call [%s] on event [%s]", payload.MessageID, event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil +} +``` + +In `NewWebsocketListener`, register: + +```go +events.MessageCallMissed: l.onMessageCallMissed, +``` + +Add: + +```go +func (listener *WebsocketListener) onMessageCallMissed(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { + msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil +} +``` + +- [ ] **Step 5: Add listener tests** + +Create `api/pkg/listeners/read_receipts_test_helpers_test.go`: + +```go +package listeners + +import ( + "context" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" +) + +type noopListenerLogger struct{} + +func (logger *noopListenerLogger) Error(error) {} +func (logger *noopListenerLogger) WithService(string) telemetry.Logger { return logger } +func (logger *noopListenerLogger) WithString(string, string) telemetry.Logger { return logger } +func (logger *noopListenerLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *noopListenerLogger) Trace(string) {} +func (logger *noopListenerLogger) Info(string) {} +func (logger *noopListenerLogger) Warn(error) {} +func (logger *noopListenerLogger) Debug(string) {} +func (logger *noopListenerLogger) Fatal(error) {} +func (logger *noopListenerLogger) Printf(string, ...interface{}) {} + +type listenerMessageThreadRepository struct { + activity repositories.MessageThreadActivityUpdate +} + +func (repository *listenerMessageThreadRepository) Store(context.Context, *entities.MessageThread) error { + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateActivity(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + repository.activity = params + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (repository *listenerMessageThreadRepository) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New()}, nil +} + +func (repository *listenerMessageThreadRepository) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{}, nil +} + +func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := []entities.MessageThread{} + return &threads, nil +} + +func (repository *listenerMessageThreadRepository) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (repository *listenerMessageThreadRepository) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} +``` + +Create `api/pkg/listeners/message_thread_listener_test.go`: + +```go +package listeners + +import ( + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageThreadListenerMarksMissedCallUnread(t *testing.T) { + repository := &listenerMessageThreadRepository{} + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil) + _, routes := NewMessageThreadListener(logger, tracer, service) + + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/messages/call-missed") + event.SetType(events.MessageCallMissed) + event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessageCallMissedPayload{ + MessageID: uuid.New(), + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), + })) + + err := routes[events.MessageCallMissed](context.Background(), event) + + require.NoError(t, err) + assert.True(t, repository.activity.MarksUnread) + assert.Equal(t, "Missed phone call", repository.activity.Content) + assert.Equal(t, event.Time(), repository.activity.EventTimestamp) +} +``` + +Create `api/pkg/listeners/websocket_listener_test.go`: + +```go +package listeners + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/pusher/pusher-http-go/v5" + "github.com/stretchr/testify/assert" +) + +func TestWebsocketListenerRegistersMissedCalls(t *testing.T) { + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + _, routes := NewWebsocketListener(logger, tracer, &pusher.Client{}) + + assert.Contains(t, routes, events.MessageCallMissed) +} +``` + +- [ ] **Step 6: Run service and listener tests** + +Run: + +```powershell +go test ./pkg/services ./pkg/listeners -run 'MessageThread|MissedCall|UpdateStatus|CreateThread' -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit service and realtime behavior** + +```powershell +git add api/pkg/services/message_thread_service.go api/pkg/services/message_thread_service_test.go api/pkg/listeners/message_thread_listener.go api/pkg/listeners/read_receipts_test_helpers_test.go api/pkg/listeners/message_thread_listener_test.go api/pkg/listeners/websocket_listener.go api/pkg/listeners/websocket_listener_test.go +@' +feat(api): track unread inbound threads + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 4: Finish the HTTP behavior and regenerate Swagger + +**Files:** +- Modify: `api/pkg/handlers/message_thread_handler.go` +- Modify: `api/docs/docs.go` +- Modify: `api/docs/swagger.json` +- Modify: `api/docs/swagger.yaml` + +**Interfaces:** +- Consumes: `responses.MessageThreadResponse` from Task 1. +- Produces: 404 behavior for missing thread updates. +- Produces: Swagger schema fields `is_read`, optional `is_archived`, optional `is_read`. + +- [ ] **Step 1: Update the handler error mapping and annotations** + +Change the update annotation: + +```go +// @Success 200 {object} responses.MessageThreadResponse +// @Failure 404 {object} responses.NotFound +``` + +Change the error block: + +```go +thread, err := h.service.UpdateStatus(ctx, request.ToUpdateParams(h.userIDFomContext(c))) +if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", request.MessageThreadID)) +} +if err != nil { + msg := fmt.Sprintf("cannot update message thread with params [%+#v]", request) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) +} +``` + +- [ ] **Step 2: Regenerate Swagger** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\api' +swag init --requiredByDefault --parseDependency --parseInternal +``` + +Expected: `api/docs/docs.go`, `api/docs/swagger.json`, and `api/docs/swagger.yaml` update successfully. + +- [ ] **Step 3: Run the full API suite** + +Run: + +```powershell +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 4: Commit HTTP and documentation changes** + +```powershell +git add api/pkg/handlers/message_thread_handler.go api/docs/docs.go api/docs/swagger.json api/docs/swagger.yaml +@' +docs(api): publish thread read state + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 5: Add automatic read updates to the web store + +**Files:** +- Modify: `web/shared/types/api.ts` +- Modify: `web/app/stores/threads.ts` + +**Interfaces:** +- Consumes: API `PUT /v1/message-threads/{id}` with `{ is_read: true }`. +- Produces: `markThreadRead(threadId: string, force?: boolean): Promise`. + +- [ ] **Step 1: Regenerate TypeScript API models** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\web' +pnpm api:models +``` + +Expected: `EntitiesMessageThread.is_read: boolean` and optional fields on `RequestsMessageThreadUpdate`. + +- [ ] **Step 2: Add a typed local replacement helper** + +In `web/app/stores/threads.ts`, add: + +```ts +function replaceThread(updatedThread: EntitiesMessageThread) { + const index = threads.value.findIndex((thread) => thread.id === updatedThread.id) + if (index !== -1) threads.value[index] = updatedThread +} +``` + +- [ ] **Step 3: Implement non-silent automatic read persistence** + +Add: + +```ts +async function markThreadRead(threadId: string, force = false) { + const thread = threads.value.find((item) => item.id === threadId) + if (!thread) throw new Error(`Cannot find thread with id ${threadId}`) + if (!force && thread.is_read) return + + try { + const response = await apiFetch<{ data: EntitiesMessageThread }>( + `/v1/message-threads/${threadId}`, + { + method: 'PUT', + body: { is_read: true }, + }, + ) + replaceThread(response.data) + } catch (error) { + notificationsStore.addNotification({ + message: 'The message thread could not be marked as read', + type: 'error', + }) + await loadThreads() + throw error + } +} +``` + +Add `markThreadRead` to the returned store API. + +Keep archive updates independent: + +```ts +body: { is_archived: payload.isArchived }, +``` + +Do not send `is_read` from `updateThread`. + +- [ ] **Step 4: Run web static checks** + +Run: + +```powershell +pnpm lint:js +pnpm lint:prettier +``` + +Expected: PASS. + +- [ ] **Step 5: Commit store and generated types** + +```powershell +git add web/shared/types/api.ts web/app/stores/threads.ts +@' +feat(web): persist opened threads as read + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 6: Mark opened threads read and highlight unread threads + +**Files:** +- Modify: `web/app/pages/threads/[id]/index.vue` +- Modify: `web/app/components/MessageThread.vue` + +**Interfaces:** +- Consumes: `threadsStore.markThreadRead(threadId, force)`. +- Produces: automatic initial and realtime read updates. +- Produces: shared unread list styling on mobile and desktop. + +- [ ] **Step 1: Make read updates independent from message loading** + +In `web/app/pages/threads/[id]/index.vue`, add: + +```ts +async function markCurrentThreadRead(force = false) { + const threadId = route.params.id as string + try { + await threadsStore.markThreadRead(threadId, force) + } catch (error) { + console.error(error) + } +} +``` + +At the start of `loadMessages`, after computing `threadId`, call without awaiting: + +```ts +void markCurrentThreadRead() +``` + +This lets message loading continue even if the read update fails. + +- [ ] **Step 2: Force a newer read watermark after inbound realtime events** + +Change the incoming SMS binding: + +```ts +webhookChannel.bind('message.phone.received', () => { + if (!loadingMessages.value) { + void markCurrentThreadRead(true) + loadMessages(false) + } +}) +``` + +Add the missed-call binding with the same behavior: + +```ts +webhookChannel.bind('message.call.missed', () => { + if (!loadingMessages.value) { + void markCurrentThreadRead(true) + loadMessages(false) + } +}) +``` + +The forced call is required because the local thread may still say `is_read: +true` when the concurrent backend listener has not committed its unread update. + +- [ ] **Step 3: Add unread presentation** + +In `MessageThread.vue`, add the class binding to `v-list-item`: + +```vue +:class="{ 'message-thread--unread': !thread.is_read }" +``` + +Change title and subtitle class bindings: + +```vue + + {{ formatPhoneNumber(thread.contact) }} + + +``` + +Add: + +```vue + +``` + +- [ ] **Step 4: Run web validation** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\web' +pnpm lint +pnpm run generate +``` + +Expected: both commands PASS. + +- [ ] **Step 5: Commit UI behavior** + +```powershell +git add web/app/pages/threads/[id]/index.vue web/app/components/MessageThread.vue +@' +feat(web): highlight unread message threads + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 7: Verify the complete feature + +**Files:** +- Review: all files changed since `origin/main` + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: a clean, verified feature branch. + +- [ ] **Step 1: Run API formatting and tests** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\api' +go-fumpt -w pkg/entities/message_thread.go pkg/entities/message_thread_test.go pkg/repositories/message_thread_repository.go pkg/repositories/gorm_message_thread_repository.go pkg/repositories/gorm_message_thread_repository_test.go pkg/services/message_thread_service.go pkg/services/message_thread_service_test.go pkg/requests/message_thread_update_request.go pkg/requests/message_thread_update_request_test.go pkg/validators/message_thread_handler_validator.go pkg/validators/message_thread_handler_validator_test.go pkg/responses/message_thead_responses.go pkg/handlers/message_thread_handler.go pkg/listeners/message_thread_listener.go pkg/listeners/message_thread_listener_test.go pkg/listeners/websocket_listener.go pkg/listeners/websocket_listener_test.go +go test ./... +``` + +Expected: formatting completes and all API tests PASS. + +- [ ] **Step 2: Run complete web validation** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\web' +pnpm lint +pnpm run generate +``` + +Expected: PASS. + +- [ ] **Step 3: Inspect the final diff** + +Run: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts' +git --no-pager diff --check origin/main...HEAD +git --no-pager diff --stat origin/main...HEAD +git --no-pager status --short --branch +``` + +Expected: no whitespace errors and no uncommitted implementation changes. + +- [ ] **Step 4: Perform manual acceptance checks against a migrated local database** + +Verify: + +1. Existing rows return `"is_read": true`. +2. A closed thread becomes highlighted after an incoming SMS. +3. A closed thread becomes highlighted after a missed call. +4. Opening the thread removes the highlight and refresh keeps it read. +5. Incoming activity while the thread is open does not leave it unread. +6. Sending and delivery-status changes do not clear an unread thread. +7. Archiving and unarchiving preserve read state. + +- [ ] **Step 5: Commit any formatting-only corrections** + +Skip this step when Step 1 produced no changes. Otherwise: + +```powershell +git add api web +@' +style: format read receipts changes + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` From 9fcec898b41c709f0e136c60de9c23e978804ba2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:15:36 +0300 Subject: [PATCH 03/22] docs: add read receipts integration coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../plans/2026-07-18-read-receipts.md | 279 +++++++++++++++++- .../specs/2026-07-18-read-receipts-design.md | 17 ++ 2 files changed, 291 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-07-18-read-receipts.md b/docs/superpowers/plans/2026-07-18-read-receipts.md index 0972a0129..9a73ea748 100644 --- a/docs/superpowers/plans/2026-07-18-read-receipts.md +++ b/docs/superpowers/plans/2026-07-18-read-receipts.md @@ -26,6 +26,7 @@ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ``` +- Add end-to-end coverage in the existing `tests/` integration project. ## File Structure @@ -52,6 +53,8 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 - `web/app/stores/threads.ts`: automatic read update and local state replacement. - `web/app/pages/threads/[id]/index.vue`: non-blocking read calls and missed-call realtime refresh. - `web/app/components/MessageThread.vue`: unread visual treatment. +- `tests/read_receipts_test.go`: Docker-stack read/unread lifecycle coverage. +- `tests/README.md`: integration coverage documentation. --- @@ -1171,10 +1174,14 @@ Expected: `api/docs/docs.go`, `api/docs/swagger.json`, and `api/docs/swagger.yam Run: ```powershell -go test ./... +go test -vet=off ./pkg/entities ./pkg/repositories ./pkg/requests ./pkg/services ./pkg/validators ./pkg/listeners +go test -vet=off ./pkg/handlers -run '^$' ``` -Expected: PASS. +Expected: PASS. Vet is disabled because clean `origin/main` has unrelated Go +1.25 non-constant-format vet failures. Handler tests are compile-checked only +because the pre-existing suite expects an API server on `localhost:8000`; Task +7 provides the HTTP integration coverage against the Docker stack. - [ ] **Step 4: Commit HTTP and documentation changes** @@ -1399,7 +1406,268 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 '@ | git commit -F - ``` -### Task 7: Verify the complete feature +### Task 7: Add integration coverage + +**Files:** +- Create: `tests/read_receipts_test.go` +- Modify: `tests/README.md` + +**Interfaces:** +- Consumes: thread index and update HTTP endpoints from Tasks 1-4. +- Consumes: incoming SMS, missed-call, and outbound message flows from Task 3. +- Produces: `TestMessageThreadReadReceipts`. + +- [ ] **Step 1: Write the end-to-end read-receipts test** + +Create `tests/read_receipts_test.go`: + +```go +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationMessageThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + IsRead bool `json:"is_read"` + LastMessageContent *string `json:"last_message_content"` +} + +func requestJSON( + ctx context.Context, + t *testing.T, + method string, + path string, + apiKey string, + payload any, + expectedStatus int, + output any, +) { + t.Helper() + + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + + request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, body) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) + + if output != nil { + require.NoError(t, json.Unmarshal(responseBody, output)) + } +} + +func fetchMessageThreads(ctx context.Context, t *testing.T, owner string) []integrationMessageThread { + t.Helper() + + var response struct { + Data []integrationMessageThread `json:"data"` + } + path := fmt.Sprintf( + "/v1/message-threads?owner=%s&skip=0&limit=20&is_archived=false", + url.QueryEscape(owner), + ) + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) + return response.Data +} + +func waitForMessageThread( + ctx context.Context, + t *testing.T, + owner string, + contact string, + timeout time.Duration, + matches func(integrationMessageThread) bool, +) integrationMessageThread { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, thread := range fetchMessageThreads(ctx, t, owner) { + if thread.Contact == contact && matches(thread) { + return thread + } + } + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("thread %s -> %s did not reach the expected state within %v", owner, contact, timeout) + return integrationMessageThread{} +} + +func markMessageThreadRead(ctx context.Context, t *testing.T, threadID string) integrationMessageThread { + t.Helper() + + var response struct { + Data integrationMessageThread `json:"data"` + } + requestJSON( + ctx, + t, + http.MethodPut, + "/v1/message-threads/"+threadID, + userAPIKey, + map[string]any{"is_read": true}, + http.StatusOK, + &response, + ) + return response.Data +} + +func TestMessageThreadReadReceipts(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + contact := randomPhoneNumber() + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/receive", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "content": "Unread inbound message", + "encrypted": false, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + http.StatusOK, + nil, + ) + + thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return !thread.IsRead + }) + assert.False(t, thread.IsRead) + + updated := markMessageThreadRead(ctx, t, thread.ID) + assert.True(t, updated.IsRead) + waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool { + return thread.IsRead + }) + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/calls/missed", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + http.StatusOK, + nil, + ) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return !thread.IsRead && + thread.LastMessageContent != nil && + *thread.LastMessageContent == "Missed phone call" + }) + assert.False(t, thread.IsRead) + + outboundContent := "Outbound activity preserves unread" + client := newAPIClient() + _, response, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: outboundContent, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return thread.LastMessageContent != nil && + *thread.LastMessageContent == outboundContent + }) + assert.False(t, thread.IsRead, "outbound activity must not clear unread state") +} +``` + +- [ ] **Step 2: Run the test before implementation is complete** + +With the Docker integration stack running, execute: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\tests' +go test -v -timeout 120s -run TestMessageThreadReadReceipts ./... +``` + +Expected before the feature implementation: FAIL because thread responses do not expose/persist `is_read`. + +- [ ] **Step 3: Update integration-test documentation** + +Add to the Test Coverage checklist in `tests/README.md`: + +```markdown +- [x] **Message thread read receipts E2E** — Incoming SMS and missed calls mark a thread unread, the existing thread update endpoint marks it read, and outbound activity preserves unread state +``` + +- [ ] **Step 4: Run the integration test against the completed stack** + +Generate credentials, start the Docker stack, run the focused test, and tear down: + +```powershell +Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\tests' +& 'C:\Program Files\Git\bin\bash.exe' generate-firebase-credentials.sh +$env:FIREBASE_CREDENTIALS = (Get-Content firebase-credentials.json -Raw | ConvertFrom-Json | ConvertTo-Json -Compress) +docker compose up -d --build --wait +docker compose wait seed +Start-Sleep -Seconds 2 +try { + go test -v -timeout 120s -run TestMessageThreadReadReceipts ./... +} finally { + docker compose down -v +} +``` + +Expected: PASS. + +- [ ] **Step 5: Commit integration coverage** + +```powershell +git add tests/read_receipts_test.go tests/README.md +@' +test: cover message thread read receipts + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 +'@ | git commit -F - +``` + +### Task 8: Verify the complete feature **Files:** - Review: all files changed since `origin/main` @@ -1414,8 +1682,9 @@ Run: ```powershell Set-Location 'C:\Users\achoa\Work\NdoleStudio\httpsms-read-receipts\api' -go-fumpt -w pkg/entities/message_thread.go pkg/entities/message_thread_test.go pkg/repositories/message_thread_repository.go pkg/repositories/gorm_message_thread_repository.go pkg/repositories/gorm_message_thread_repository_test.go pkg/services/message_thread_service.go pkg/services/message_thread_service_test.go pkg/requests/message_thread_update_request.go pkg/requests/message_thread_update_request_test.go pkg/validators/message_thread_handler_validator.go pkg/validators/message_thread_handler_validator_test.go pkg/responses/message_thead_responses.go pkg/handlers/message_thread_handler.go pkg/listeners/message_thread_listener.go pkg/listeners/message_thread_listener_test.go pkg/listeners/websocket_listener.go pkg/listeners/websocket_listener_test.go -go test ./... +go-fumpt -w pkg/entities/message_thread.go pkg/entities/message_thread_test.go pkg/repositories/message_thread_repository.go pkg/repositories/gorm_message_thread_repository.go pkg/repositories/gorm_message_thread_repository_test.go pkg/services/message_thread_service.go pkg/services/message_thread_service_test.go pkg/requests/message_thread_update_request.go pkg/requests/message_thread_update_request_test.go pkg/validators/message_thread_handler_validator.go pkg/validators/message_thread_handler_validator_test.go pkg/responses/message_thead_responses.go pkg/handlers/message_thread_handler.go pkg/listeners/message_thread_listener.go pkg/listeners/read_receipts_test_helpers_test.go pkg/listeners/message_thread_listener_test.go pkg/listeners/websocket_listener.go pkg/listeners/websocket_listener_test.go +go test -vet=off ./pkg/entities ./pkg/repositories ./pkg/requests ./pkg/services ./pkg/validators ./pkg/listeners +go test -vet=off ./pkg/handlers -run '^$' ``` Expected: formatting completes and all API tests PASS. diff --git a/docs/superpowers/specs/2026-07-18-read-receipts-design.md b/docs/superpowers/specs/2026-07-18-read-receipts-design.md index d632755f1..6462cbaa5 100644 --- a/docs/superpowers/specs/2026-07-18-read-receipts-design.md +++ b/docs/superpowers/specs/2026-07-18-read-receipts-design.md @@ -250,6 +250,23 @@ pnpm lint pnpm run generate ``` +### Integration + +Add `tests/read_receipts_test.go` to exercise the feature against the Docker +integration stack: + +- an inbound SMS creates or updates an unread thread; +- marking the thread read through the existing update endpoint persists; +- a missed call makes the thread unread again; +- subsequent outbound activity does not clear that unread state. + +Update `tests/README.md` coverage and run: + +```bash +cd tests +go test -v -timeout 120s -run TestMessageThreadReadReceipts ./... +``` + Manual acceptance checks: 1. Existing threads appear read immediately after migration. From 1e6259f0a98a67c3beb62f6423f1ab946afb1736 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:19:42 +0300 Subject: [PATCH 04/22] feat(api): add thread read state contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/pkg/entities/message_thread.go | 2 ++ api/pkg/entities/message_thread_test.go | 25 ++++++++++++++ .../requests/message_thread_update_request.go | 4 ++- .../message_thread_update_request_test.go | 25 ++++++++++++++ api/pkg/responses/message_thead_responses.go | 6 ++++ api/pkg/services/message_thread_service.go | 11 +++--- .../message_thread_handler_validator.go | 10 +++++- .../message_thread_handler_validator_test.go | 34 +++++++++++++++++++ 8 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 api/pkg/entities/message_thread_test.go create mode 100644 api/pkg/requests/message_thread_update_request_test.go create mode 100644 api/pkg/validators/message_thread_handler_validator_test.go diff --git a/api/pkg/entities/message_thread.go b/api/pkg/entities/message_thread.go index 2eb1e2d33..3766acc77 100644 --- a/api/pkg/entities/message_thread.go +++ b/api/pkg/entities/message_thread.go @@ -12,6 +12,8 @@ type MessageThread struct { Owner string `json:"owner" example:"+18005550199"` Contact string `json:"contact" example:"+18005550100"` IsArchived bool `json:"is_archived" example:"false"` + IsRead bool `json:"is_read" gorm:"not null;default:true" example:"true"` + LastReadAt time.Time `json:"-" gorm:"not null;default:CURRENT_TIMESTAMP"` UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` Color string `json:"color" example:"indigo"` Status MessageStatus `json:"status" example:"PENDING"` diff --git a/api/pkg/entities/message_thread_test.go b/api/pkg/entities/message_thread_test.go new file mode 100644 index 000000000..1409dff29 --- /dev/null +++ b/api/pkg/entities/message_thread_test.go @@ -0,0 +1,25 @@ +package entities + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) { + threadType := reflect.TypeOf(MessageThread{}) + + isRead, ok := threadType.FieldByName("IsRead") + require.True(t, ok) + assert.Contains(t, isRead.Tag.Get("gorm"), "not null") + assert.Contains(t, isRead.Tag.Get("gorm"), "default:true") + assert.Equal(t, "is_read", isRead.Tag.Get("json")) + + lastReadAt, ok := threadType.FieldByName("LastReadAt") + require.True(t, ok) + assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null") + assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP") + assert.Equal(t, "-", lastReadAt.Tag.Get("json")) +} diff --git a/api/pkg/requests/message_thread_update_request.go b/api/pkg/requests/message_thread_update_request.go index d64d75c59..3309fc91d 100644 --- a/api/pkg/requests/message_thread_update_request.go +++ b/api/pkg/requests/message_thread_update_request.go @@ -10,7 +10,8 @@ import ( // MessageThreadUpdate is the payload for updating a message thread type MessageThreadUpdate struct { request - IsArchived bool `json:"is_archived" example:"true"` + IsArchived *bool `json:"is_archived,omitempty" example:"true"` + IsRead *bool `json:"is_read,omitempty" example:"true"` MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` // used internally for validation } @@ -21,5 +22,6 @@ func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) service UserID: userID, MessageThreadID: uuid.MustParse(input.MessageThreadID), IsArchived: input.IsArchived, + IsRead: input.IsRead, } } diff --git a/api/pkg/requests/message_thread_update_request_test.go b/api/pkg/requests/message_thread_update_request_test.go new file mode 100644 index 000000000..9f9579fda --- /dev/null +++ b/api/pkg/requests/message_thread_update_request_test.go @@ -0,0 +1,25 @@ +package requests + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) { + threadID := uuid.New() + isRead := true + input := MessageThreadUpdate{ + MessageThreadID: threadID.String(), + IsRead: &isRead, + } + + params := input.ToUpdateParams(entities.UserID("user-id")) + + assert.Equal(t, threadID, params.MessageThreadID) + assert.Equal(t, entities.UserID("user-id"), params.UserID) + assert.Nil(t, params.IsArchived) + assert.Same(t, &isRead, params.IsRead) +} diff --git a/api/pkg/responses/message_thead_responses.go b/api/pkg/responses/message_thead_responses.go index 917706a47..e47fec287 100644 --- a/api/pkg/responses/message_thead_responses.go +++ b/api/pkg/responses/message_thead_responses.go @@ -7,3 +7,9 @@ type MessageThreadsResponse struct { response Data []entities.MessageThread `json:"data"` } + +// MessageThreadResponse is the payload containing entities.MessageThread +type MessageThreadResponse struct { + response + Data entities.MessageThread `json:"data"` +} diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index d2027506f..0daa78019 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -103,7 +103,8 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me // MessageThreadStatusParams are parameters for updating a thread status type MessageThreadStatusParams struct { - IsArchived bool + IsArchived *bool + IsRead *bool UserID entities.UserID MessageThreadID uuid.UUID } @@ -121,9 +122,11 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - if err = service.repository.Update(ctx, thread.UpdateArchive(params.IsArchived)); err != nil { - msg := fmt.Sprintf("cannot update message thread with id [%s] with archive status [%t]", thread.ID, params.IsArchived) - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + if params.IsArchived != nil { + if err = service.repository.Update(ctx, thread.UpdateArchive(*params.IsArchived)); err != nil { + msg := fmt.Sprintf("cannot update message thread with id [%s] with archive status [%t]", thread.ID, *params.IsArchived) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } } ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with archive status [%t]", thread.ID, thread.IsArchived)) diff --git a/api/pkg/validators/message_thread_handler_validator.go b/api/pkg/validators/message_thread_handler_validator.go index a4e5cd647..72a64194f 100644 --- a/api/pkg/validators/message_thread_handler_validator.go +++ b/api/pkg/validators/message_thread_handler_validator.go @@ -72,5 +72,13 @@ func (validator *MessageThreadHandlerValidator) ValidateUpdate(_ context.Context }, }) - return v.ValidateStruct() + errors := v.ValidateStruct() + if request.IsArchived == nil && request.IsRead == nil { + if errors == nil { + errors = url.Values{} + } + errors.Add("payload", "at least one of is_archived or is_read is required") + } + + return errors } diff --git a/api/pkg/validators/message_thread_handler_validator_test.go b/api/pkg/validators/message_thread_handler_validator_test.go new file mode 100644 index 000000000..e5ec7c1b7 --- /dev/null +++ b/api/pkg/validators/message_thread_handler_validator_test.go @@ -0,0 +1,34 @@ +package validators + +import ( + "context" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestValidateUpdateRequiresAtLeastOneStatusField(t *testing.T) { + validator := &MessageThreadHandlerValidator{} + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + } + + errors := validator.ValidateUpdate(context.Background(), request) + + assert.NotEmpty(t, errors.Get("payload")) +} + +func TestValidateUpdateAcceptsReadOnlyUpdate(t *testing.T) { + validator := &MessageThreadHandlerValidator{} + isRead := true + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + IsRead: &isRead, + } + + errors := validator.ValidateUpdate(context.Background(), request) + + assert.Empty(t, errors) +} From d279bd93fe9688a20a32541b57fe5816bde815c2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:31:31 +0300 Subject: [PATCH 05/22] refactor(api): make thread updates atomic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../gorm_message_thread_repository.go | 111 +++++++++++++++--- .../gorm_message_thread_repository_test.go | 58 +++++++++ .../repositories/message_thread_repository.go | 35 +++++- 3 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 api/pkg/repositories/gorm_message_thread_repository_test.go diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index 244e8b585..4fde3b4e9 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -35,6 +35,29 @@ func NewGormMessageThreadRepository( } } +func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string]any { + return map[string]any{ + "order_timestamp": params.Timestamp, + "last_message_id": params.MessageID, + "last_message_content": params.Content, + "status": string(params.Status), + } +} + +func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any { + updates := make(map[string]any) + if params.IsArchived != nil { + updates["is_archived"] = *params.IsArchived + } + if params.IsRead != nil { + updates["is_read"] = *params.IsRead + if *params.IsRead { + updates["last_read_at"] = params.ReadAt + } + } + return updates +} + func (repository *gormMessageThreadRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -62,21 +85,22 @@ func (repository *gormMessageThreadRepository) Delete(ctx context.Context, userI } // UpdateAfterDeletedMessage updates a thread after the original message has been deleted -func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error { +func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - err := repository.db.WithContext(ctx).Model(&entities.MessageThread{}). - Where("user_id = ?", userID). - Where("last_message_id = ?", messageID). + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). Updates(map[string]any{ - "last_message_id": nil, - "last_message_content": nil, - "status": entities.MessageStatusDeleted, - }).Error - if err != nil { - msg := fmt.Sprintf("cannot update thread after message is deleted with userID [%s] and messageID [%s]", userID, messageID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + "last_message_id": params.LastMessageID, + "last_message_content": params.LastMessageContent, + "status": string(params.LastMessageStatus), + }) + if result.Error != nil { + msg := fmt.Sprintf("cannot update deleted-message metadata for thread [%s]", params.MessageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) } return nil @@ -95,14 +119,69 @@ func (repository *gormMessageThreadRepository) Store(ctx context.Context, thread return nil } -// Update a new entities.MessageThread -func (repository *gormMessageThreadRepository) Update(ctx context.Context, thread *entities.MessageThread) error { +// UpdateActivity persists the last-message activity fields for a thread +func (repository *gormMessageThreadRepository) UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Save(thread).Error; err != nil { - msg := fmt.Sprintf("cannot update message thread thread with ID [%s]", thread.ID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID) + + result := query.Updates(messageThreadActivityUpdates(params)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return stacktrace.PropagateWithCode( + gorm.ErrRecordNotFound, + ErrCodeNotFound, + fmt.Sprintf("thread with id [%s] not found", params.MessageThreadID), + ) + } + + if !params.MarksUnread { + return nil + } + + return tx.Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Where("last_read_at < ?", params.EventTimestamp). + Update("is_read", false). + Error + }) + if err != nil { + msg := fmt.Sprintf("cannot update message activity for thread [%s]", params.MessageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } + + return nil +} + +// UpdateStatus persists archive/read status fields for a thread +func (repository *gormMessageThreadRepository) UpdateStatus( + ctx context.Context, + userID entities.UserID, + messageThreadID uuid.UUID, + params MessageThreadStatusUpdate, +) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", userID). + Where("id = ?", messageThreadID). + Updates(messageThreadStatusUpdates(params)) + if result.Error != nil { + msg := fmt.Sprintf("cannot update status for thread [%s]", messageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) + } + if result.RowsAffected == 0 { + msg := fmt.Sprintf("thread with id [%s] not found", messageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, msg)) } return nil diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go new file mode 100644 index 000000000..10a13b077 --- /dev/null +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -0,0 +1,58 @@ +package repositories + +import ( + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { + messageID := uuid.New() + updates := messageThreadActivityUpdates(MessageThreadActivityUpdate{ + Timestamp: time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + MessageID: messageID, + Content: "hello", + Status: entities.MessageStatusReceived, + }) + + assert.Equal(t, map[string]any{ + "order_timestamp": time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + "last_message_id": messageID, + "last_message_content": "hello", + "status": entities.MessageStatusReceived, + }, updates) + assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "is_archived") + assert.NotContains(t, updates, "last_read_at") +} + +func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) { + isRead := true + readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC) + + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsRead: &isRead, + ReadAt: readAt, + }) + + assert.Equal(t, map[string]any{ + "is_read": true, + "last_read_at": readAt, + }, updates) + assert.NotContains(t, updates, "is_archived") +} + +func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) { + isArchived := true + + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsArchived: &isArchived, + }) + + assert.Equal(t, map[string]any{"is_archived": true}, updates) + assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "last_read_at") +} diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go index 542fd519b..aec8aafdc 100644 --- a/api/pkg/repositories/message_thread_repository.go +++ b/api/pkg/repositories/message_thread_repository.go @@ -2,19 +2,48 @@ package repositories import ( "context" + "time" "github.com/google/uuid" "github.com/NdoleStudio/httpsms/pkg/entities" ) +type MessageThreadActivityUpdate struct { + MessageThreadID uuid.UUID + UserID entities.UserID + Timestamp time.Time + MessageID uuid.UUID + Content string + Status entities.MessageStatus + MarksUnread bool + EventTimestamp time.Time +} + +type MessageThreadStatusUpdate struct { + IsArchived *bool + IsRead *bool + ReadAt time.Time +} + +type MessageThreadDeletedUpdate struct { + MessageThreadID uuid.UUID + UserID entities.UserID + LastMessageID *uuid.UUID + LastMessageContent *string + LastMessageStatus entities.MessageStatus +} + // MessageThreadRepository loads and persists an entities.MessageThread type MessageThreadRepository interface { // Store a new entities.MessageThread Store(ctx context.Context, thread *entities.MessageThread) error - // Update a new entities.MessageThread - Update(ctx context.Context, thread *entities.MessageThread) error + // UpdateActivity persists the last-message activity fields for a thread + UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error + + // UpdateStatus persists archive/read status fields for a thread + UpdateStatus(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID, params MessageThreadStatusUpdate) error // LoadByOwnerContact fetches a thread between owner and contact LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) @@ -26,7 +55,7 @@ type MessageThreadRepository interface { Index(ctx context.Context, userID entities.UserID, owner string, archived bool, params IndexParams) (*[]entities.MessageThread, error) // UpdateAfterDeletedMessage updates a thread after the original message has been deleted - UpdateAfterDeletedMessage(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error + UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error // Delete an entities.MessageThread by ID Delete(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID) error From 6add7f1aa2c6131ed5f8fc4147863e2b362c8d1d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:39:19 +0300 Subject: [PATCH 06/22] feat(api): track unread inbound threads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/pkg/listeners/message_thread_listener.go | 46 +++- .../listeners/message_thread_listener_test.go | 72 ++++++ .../read_receipts_test_helpers_test.go | 66 ++++++ api/pkg/listeners/websocket_listener.go | 18 ++ api/pkg/listeners/websocket_listener_test.go | 18 ++ api/pkg/services/message_thread_service.go | 72 +++--- .../services/message_thread_service_test.go | 224 ++++++++++++++++++ 7 files changed, 480 insertions(+), 36 deletions(-) create mode 100644 api/pkg/listeners/message_thread_listener_test.go create mode 100644 api/pkg/listeners/read_receipts_test_helpers_test.go create mode 100644 api/pkg/listeners/websocket_listener_test.go create mode 100644 api/pkg/services/message_thread_service_test.go diff --git a/api/pkg/listeners/message_thread_listener.go b/api/pkg/listeners/message_thread_listener.go index e86c8092f..4d1a4ea9c 100644 --- a/api/pkg/listeners/message_thread_listener.go +++ b/api/pkg/listeners/message_thread_listener.go @@ -40,6 +40,7 @@ func NewMessageThreadListener( events.EventTypeMessagePhoneDelivered: l.OnMessagePhoneDelivered, events.EventTypeMessageSendFailed: l.OnMessagePhoneFailed, events.EventTypeMessagePhoneReceived: l.OnMessagePhoneReceived, + events.MessageCallMissed: l.OnMessageCallMissed, events.EventTypeMessageNotificationScheduled: l.onMessageNotificationScheduled, events.EventTypeMessageSendExpired: l.onMessageExpired, events.UserAccountDeleted: l.onUserAccountDeleted, @@ -222,13 +223,15 @@ func (listener *MessageThreadListener) OnMessagePhoneReceived(ctx context.Contex } updateParams := services.MessageThreadUpdateParams{ - Owner: payload.Owner, - Contact: payload.Contact, - Timestamp: payload.Timestamp, - UserID: payload.UserID, - Status: entities.MessageStatusReceived, - Content: payload.Content, - MessageID: payload.MessageID, + Owner: payload.Owner, + Contact: payload.Contact, + Timestamp: payload.Timestamp, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Content: payload.Content, + MessageID: payload.MessageID, + MarksUnread: true, + EventTimestamp: event.Time(), } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { @@ -239,6 +242,35 @@ func (listener *MessageThreadListener) OnMessagePhoneReceived(ctx context.Contex return nil } +// OnMessageCallMissed handles the events.MessageCallMissed event +func (listener *MessageThreadListener) OnMessageCallMissed(ctx context.Context, event cloudevents.Event) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + params := services.MessageThreadUpdateParams{ + Owner: payload.Owner, + Contact: payload.Contact, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Timestamp: payload.Timestamp, + Content: "Missed phone call", + MessageID: payload.MessageID, + MarksUnread: true, + EventTimestamp: event.Time(), + } + if err := listener.service.UpdateThread(ctx, params); err != nil { + msg := fmt.Sprintf("cannot update thread for missed call [%s] on event [%s]", payload.MessageID, event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil +} + // onMessageNotificationScheduled handles the events.EventTypeMessageNotificationScheduled event func (listener *MessageThreadListener) onMessageNotificationScheduled(ctx context.Context, event cloudevents.Event) error { ctx, span := listener.tracer.Start(ctx) diff --git a/api/pkg/listeners/message_thread_listener_test.go b/api/pkg/listeners/message_thread_listener_test.go new file mode 100644 index 000000000..be6e3ea18 --- /dev/null +++ b/api/pkg/listeners/message_thread_listener_test.go @@ -0,0 +1,72 @@ +package listeners + +import ( + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageThreadListenerMarksInboundMessageUnread(t *testing.T) { + repository, routes := newMessageThreadListenerForTest() + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/messages/phone-received") + event.SetType(events.EventTypeMessagePhoneReceived) + event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessagePhoneReceivedPayload{ + MessageID: uuid.New(), + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + Content: "hello", + Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), + })) + + err := routes[events.EventTypeMessagePhoneReceived](context.Background(), event) + + require.NoError(t, err) + assert.True(t, repository.activity.MarksUnread) + assert.Equal(t, event.Time(), repository.activity.EventTimestamp) +} + +func TestMessageThreadListenerMarksMissedCallUnread(t *testing.T) { + repository, routes := newMessageThreadListenerForTest() + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/messages/call-missed") + event.SetType(events.MessageCallMissed) + event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessageCallMissedPayload{ + MessageID: uuid.New(), + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), + })) + require.Contains(t, routes, events.MessageCallMissed) + + err := routes[events.MessageCallMissed](context.Background(), event) + + require.NoError(t, err) + assert.True(t, repository.activity.MarksUnread) + assert.Equal(t, "Missed phone call", repository.activity.Content) + assert.Equal(t, event.Time(), repository.activity.EventTimestamp) +} + +func newMessageThreadListenerForTest() (*listenerMessageThreadRepository, map[string]events.EventListener) { + repository := &listenerMessageThreadRepository{} + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil) + _, routes := NewMessageThreadListener(logger, tracer, service) + return repository, routes +} diff --git a/api/pkg/listeners/read_receipts_test_helpers_test.go b/api/pkg/listeners/read_receipts_test_helpers_test.go new file mode 100644 index 000000000..db4a14227 --- /dev/null +++ b/api/pkg/listeners/read_receipts_test_helpers_test.go @@ -0,0 +1,66 @@ +package listeners + +import ( + "context" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" +) + +type noopListenerLogger struct{} + +func (logger *noopListenerLogger) Error(error) {} +func (logger *noopListenerLogger) WithService(string) telemetry.Logger { return logger } +func (logger *noopListenerLogger) WithString(string, string) telemetry.Logger { return logger } +func (logger *noopListenerLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *noopListenerLogger) Trace(string) {} +func (logger *noopListenerLogger) Info(string) {} +func (logger *noopListenerLogger) Warn(error) {} +func (logger *noopListenerLogger) Debug(string) {} +func (logger *noopListenerLogger) Fatal(error) {} +func (logger *noopListenerLogger) Printf(string, ...interface{}) {} + +type listenerMessageThreadRepository struct { + activity repositories.MessageThreadActivityUpdate +} + +func (repository *listenerMessageThreadRepository) Store(context.Context, *entities.MessageThread) error { + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateActivity(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + repository.activity = params + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { + return nil +} + +func (repository *listenerMessageThreadRepository) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (repository *listenerMessageThreadRepository) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New()}, nil +} + +func (repository *listenerMessageThreadRepository) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{}, nil +} + +func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := []entities.MessageThread{} + return &threads, nil +} + +func (repository *listenerMessageThreadRepository) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (repository *listenerMessageThreadRepository) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} diff --git a/api/pkg/listeners/websocket_listener.go b/api/pkg/listeners/websocket_listener.go index 2c0e2c173..fa8b70c9e 100644 --- a/api/pkg/listeners/websocket_listener.go +++ b/api/pkg/listeners/websocket_listener.go @@ -36,9 +36,27 @@ func NewWebsocketListener( events.EventTypeMessagePhoneSent: l.onMessagePhoneSent, events.EventTypeMessageSendFailed: l.onMessagePhoneFailed, events.EventTypeMessagePhoneReceived: l.onMessagePhoneReceived, + events.MessageCallMissed: l.onMessageCallMissed, } } +func (listener *WebsocketListener) onMessageCallMissed(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { + msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil +} + // onMessagePhoneSent handles the events.EventTypeMessagePhoneSent event func (listener *WebsocketListener) onMessagePhoneSent(ctx context.Context, event cloudevents.Event) error { ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) diff --git a/api/pkg/listeners/websocket_listener_test.go b/api/pkg/listeners/websocket_listener_test.go new file mode 100644 index 000000000..fdaf1c6d5 --- /dev/null +++ b/api/pkg/listeners/websocket_listener_test.go @@ -0,0 +1,18 @@ +package listeners + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/pusher/pusher-http-go/v5" + "github.com/stretchr/testify/assert" +) + +func TestWebsocketListenerRegistersMissedCalls(t *testing.T) { + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + _, routes := NewWebsocketListener(logger, tracer, &pusher.Client{}) + + assert.Contains(t, routes, events.MessageCallMissed) +} diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index 0daa78019..6b0cccd82 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -41,13 +41,15 @@ func NewMessageThreadService( // MessageThreadUpdateParams are parameters for updating a thread type MessageThreadUpdateParams struct { - Owner string - Status entities.MessageStatus - Contact string - Content string - UserID entities.UserID - MessageID uuid.UUID - Timestamp time.Time + Owner string + Status entities.MessageStatus + Contact string + Content string + UserID entities.UserID + MessageID uuid.UUID + Timestamp time.Time + MarksUnread bool + EventTimestamp time.Time } // DeleteAllForUser deletes all entities.MessageThread for an entities.UserID. @@ -92,9 +94,18 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me return nil } - if err = service.repository.Update(ctx, thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)); err != nil { + if err = service.repository.UpdateActivity(ctx, repositories.MessageThreadActivityUpdate{ + MessageThreadID: thread.ID, + UserID: params.UserID, + Timestamp: params.Timestamp, + MessageID: params.MessageID, + Content: params.Content, + Status: params.Status, + MarksUnread: params.MarksUnread, + EventTimestamp: params.EventTimestamp, + }); err != nil { msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with last message [%s] and status [%s]", thread.ID, thread.LastMessageID, thread.Status)) @@ -114,22 +125,21 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me ctx, span := service.tracer.Start(ctx) defer span.End() - ctxLogger := service.tracer.CtxLogger(service.logger, span) + update := repositories.MessageThreadStatusUpdate{ + IsArchived: params.IsArchived, + IsRead: params.IsRead, + ReadAt: time.Now().UTC(), + } + if err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { + msg := fmt.Sprintf("cannot update message thread with id [%s]", params.MessageThreadID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) if err != nil { - msg := fmt.Sprintf("cannot find thread with id [%s]", params.MessageThreadID) - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - - if params.IsArchived != nil { - if err = service.repository.Update(ctx, thread.UpdateArchive(*params.IsArchived)); err != nil { - msg := fmt.Sprintf("cannot update message thread with id [%s] with archive status [%t]", thread.ID, *params.IsArchived) - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } + msg := fmt.Sprintf("cannot reload message thread with id [%s]", params.MessageThreadID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } - - ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with archive status [%t]", thread.ID, thread.IsArchived)) return thread, nil } @@ -161,12 +171,13 @@ func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Conte return nil } - thread.LastMessageContent = payload.PreviousMessageContent - thread.LastMessageID = payload.PreviousMessageID - thread.Status = *payload.PreviousMessageStatus - thread.UpdatedAt = time.Now().UTC() - - if err = service.repository.Update(ctx, thread); err != nil { + if err = service.repository.UpdateAfterDeletedMessage(ctx, repositories.MessageThreadDeletedUpdate{ + MessageThreadID: thread.ID, + UserID: thread.UserID, + LastMessageID: payload.PreviousMessageID, + LastMessageContent: payload.PreviousMessageContent, + LastMessageStatus: *payload.PreviousMessageStatus, + }); err != nil { msg := fmt.Sprintf("cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -181,18 +192,21 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me ctxLogger := service.tracer.CtxLogger(service.logger, span) + now := time.Now().UTC() thread := &entities.MessageThread{ ID: uuid.New(), Owner: params.Owner, Contact: params.Contact, UserID: params.UserID, IsArchived: false, + IsRead: !params.MarksUnread, + LastReadAt: now, Color: service.getColor(), LastMessageContent: ¶ms.Content, Status: params.Status, LastMessageID: ¶ms.MessageID, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + CreatedAt: now, + UpdatedAt: now, OrderTimestamp: params.Timestamp, } diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go new file mode 100644 index 000000000..824f6b10c --- /dev/null +++ b/api/pkg/services/message_thread_service_test.go @@ -0,0 +1,224 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/palantir/stacktrace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +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) + store func(context.Context, *entities.MessageThread) error + updateActivity func(context.Context, repositories.MessageThreadActivityUpdate) error + updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error +} + +func (stub *messageThreadRepositoryStub) Store(ctx context.Context, thread *entities.MessageThread) error { + if stub.store != nil { + return stub.store(ctx, thread) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateActivity(ctx context.Context, params repositories.MessageThreadActivityUpdate) error { + if stub.updateActivity != nil { + return stub.updateActivity(ctx, params) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateStatus(ctx context.Context, userID entities.UserID, threadID uuid.UUID, params repositories.MessageThreadStatusUpdate) error { + if stub.updateStatus != nil { + return stub.updateStatus(ctx, userID, threadID, params) + } + return nil +} + +func (stub *messageThreadRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (stub *messageThreadRepositoryStub) LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) { + return stub.loadByOwnerContact(ctx, userID, owner, contact) +} + +func (stub *messageThreadRepositoryStub) Load(ctx context.Context, userID entities.UserID, id uuid.UUID) (*entities.MessageThread, error) { + return stub.load(ctx, userID, id) +} + +func (stub *messageThreadRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + threads := []entities.MessageThread{} + return &threads, nil +} + +func (stub *messageThreadRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (stub *messageThreadRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +func newMessageThreadServiceForTest(repository repositories.MessageThreadRepository) *MessageThreadService { + logger := &noopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewMessageThreadService(logger, tracer, repository, nil) +} + +func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { + threadID := uuid.New() + eventTimestamp := time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC) + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: eventTimestamp, + MarksUnread: true, + EventTimestamp: eventTimestamp, + }) + + require.NoError(t, err) + assert.True(t, captured.MarksUnread) + assert.Equal(t, eventTimestamp, captured.EventTimestamp) +} + +func TestUpdateThreadPreservesReadStateForOutboundActivity(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(), IsRead: false}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "outbound", + Status: entities.MessageStatusSent, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + assert.False(t, captured.MarksUnread) +} + +func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { + tests := []struct { + name string + marksUnread bool + wantRead bool + }{ + {name: "inbound", marksUnread: true, wantRead: false}, + {name: "outbound", marksUnread: false, wantRead: true}, + } + + 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.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + store: func(_ context.Context, thread *entities.MessageThread) error { + stored = thread + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: time.Now().UTC(), + MarksUnread: test.marksUnread, + }) + + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, test.wantRead, stored.IsRead) + assert.False(t, stored.LastReadAt.IsZero()) + }) + } +} + +func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { + threadID := uuid.New() + isRead := true + var captured repositories.MessageThreadStatusUpdate + repository := &messageThreadRepositoryStub{ + updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) error { + captured = params + return nil + }, + load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: true}, nil + }, + } + + service := newMessageThreadServiceForTest(repository) + thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: threadID, + IsRead: &isRead, + }) + + require.NoError(t, err) + assert.Nil(t, captured.IsArchived) + assert.Same(t, &isRead, captured.IsRead) + assert.False(t, captured.ReadAt.IsZero()) + assert.True(t, thread.IsArchived) +} + +func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { + repository := &messageThreadRepositoryStub{ + updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { + return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + } + + service := newMessageThreadServiceForTest(repository) + isRead := true + _, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: uuid.New(), + IsRead: &isRead, + }) + + assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) +} From 6e0bc3b3f41b5363294d9473e6ad395bd0653953 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:46:52 +0300 Subject: [PATCH 07/22] docs(api): publish thread read state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/docs/docs.go | 43 ++++++- api/docs/swagger.json | 43 ++++++- api/docs/swagger.yaml | 32 +++++- api/pkg/handlers/message_thread_handler.go | 6 +- .../handlers/message_thread_handler_test.go | 106 ++++++++++++++++++ 5 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 api/pkg/handlers/message_thread_handler_test.go diff --git a/api/docs/docs.go b/api/docs/docs.go index c57458255..969a68a20 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -871,7 +871,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.PhoneResponse" + "$ref": "#/definitions/responses.MessageThreadResponse" } }, "400": { @@ -886,6 +886,12 @@ const docTemplate = `{ "$ref": "#/definitions/responses.Unauthorized" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, "422": { "description": "Unprocessable Entity", "schema": { @@ -3379,7 +3385,7 @@ const docTemplate = `{ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3691,6 +3697,7 @@ const docTemplate = `{ "created_at", "id", "is_archived", + "is_read", "last_message_content", "last_message_id", "order_timestamp", @@ -3720,6 +3727,10 @@ const docTemplate = `{ "type": "boolean", "example": false }, + "is_read": { + "type": "boolean", + "example": true + }, "last_message_content": { "type": "string", "example": "This is a sample message content" @@ -4382,13 +4393,14 @@ const docTemplate = `{ }, "requests.MessageThreadUpdate": { "type": "object", - "required": [ - "is_archived" - ], "properties": { "is_archived": { "type": "boolean", "example": true + }, + "is_read": { + "type": "boolean", + "example": true } } }, @@ -4892,6 +4904,27 @@ const docTemplate = `{ } } }, + "responses.MessageThreadResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.MessageThread" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.MessageThreadsResponse": { "type": "object", "required": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 009797ca9..a985b1a24 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -868,7 +868,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.PhoneResponse" + "$ref": "#/definitions/responses.MessageThreadResponse" } }, "400": { @@ -883,6 +883,12 @@ "$ref": "#/definitions/responses.Unauthorized" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, "422": { "description": "Unprocessable Entity", "schema": { @@ -3376,7 +3382,7 @@ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3688,6 +3694,7 @@ "created_at", "id", "is_archived", + "is_read", "last_message_content", "last_message_id", "order_timestamp", @@ -3717,6 +3724,10 @@ "type": "boolean", "example": false }, + "is_read": { + "type": "boolean", + "example": true + }, "last_message_content": { "type": "string", "example": "This is a sample message content" @@ -4379,13 +4390,14 @@ }, "requests.MessageThreadUpdate": { "type": "object", - "required": [ - "is_archived" - ], "properties": { "is_archived": { "type": "boolean", "example": true + }, + "is_read": { + "type": "boolean", + "example": true } } }, @@ -4889,6 +4901,27 @@ } } }, + "responses.MessageThreadResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.MessageThread" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.MessageThreadsResponse": { "type": "object", "required": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index bb0041717..22f28eae7 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -58,7 +58,7 @@ definitions: example: 30 type: integer request_id: - example: bulk-csv-a1B2c3D4e5 + example: bulk-httpsms-file.csv type: string scheduled_count: example: 50 @@ -319,6 +319,9 @@ definitions: is_archived: example: false type: boolean + is_read: + example: true + type: boolean last_message_content: example: This is a sample message content type: string @@ -346,6 +349,7 @@ definitions: - created_at - id - is_archived + - is_read - last_message_content - last_message_id - order_timestamp @@ -847,8 +851,9 @@ definitions: is_archived: example: true type: boolean - required: - - is_archived + is_read: + example: true + type: boolean type: object requests.PhoneAPIKeyStoreRequest: properties: @@ -1215,6 +1220,21 @@ definitions: - message - status type: object + responses.MessageThreadResponse: + properties: + data: + $ref: '#/definitions/entities.MessageThread' + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object responses.MessageThreadsResponse: properties: data: @@ -2170,7 +2190,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/responses.PhoneResponse' + $ref: '#/definitions/responses.MessageThreadResponse' "400": description: Bad Request schema: @@ -2179,6 +2199,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/responses.Unauthorized' + "404": + description: Not Found + schema: + $ref: '#/definitions/responses.NotFound' "422": description: Unprocessable Entity schema: diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 1ff043218..3cef7de64 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -103,9 +103,10 @@ func (h *MessageThreadHandler) Index(c fiber.Ctx) error { // @Produce json // @Param messageThreadID path string true "ID of the message thread" default(32343a19-da5e-4b1b-a767-3298a73703ca) // @Param payload body requests.MessageThreadUpdate true "Payload of message thread details to update" -// @Success 200 {object} responses.PhoneResponse +// @Success 200 {object} responses.MessageThreadResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError // @Router /message-threads/{messageThreadID} [put] @@ -128,6 +129,9 @@ func (h *MessageThreadHandler) Update(c fiber.Ctx) error { } thread, err := h.service.UpdateStatus(ctx, request.ToUpdateParams(h.userIDFomContext(c))) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", request.MessageThreadID)) + } if err != nil { msg := fmt.Sprintf("cannot update message thread with params [%+#v]", request) ctxLogger.Error(stacktrace.Propagate(err, msg)) diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go new file mode 100644 index 000000000..eac390572 --- /dev/null +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/middlewares" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/palantir/stacktrace" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "go.opentelemetry.io/otel/trace" +) + +type messageThreadHandlerRepositoryStub struct{} + +func (stub *messageThreadHandlerRepositoryStub) Store(context.Context, *entities.MessageThread) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) UpdateActivity(context.Context, repositories.MessageThreadActivityUpdate) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { + return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") +} + +func (stub *messageThreadHandlerRepositoryStub) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return nil, nil +} + +func (stub *messageThreadHandlerRepositoryStub) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return nil, nil +} + +func (stub *messageThreadHandlerRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { + return nil, nil +} + +func (stub *messageThreadHandlerRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (stub *messageThreadHandlerRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +func TestMessageThreadHandlerUpdate_ReturnsNotFoundWhenThreadIsMissing(t *testing.T) { + logger := &messageThreadHandlerNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, &messageThreadHandlerRepositoryStub{}, 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) + + messageThreadID := uuid.New() + req := httptest.NewRequest(http.MethodPut, "/v1/message-threads/"+messageThreadID.String(), bytes.NewBufferString(`{"is_read":true}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + var payload struct { + Message string `json:"message"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + require.Equal(t, "cannot find message thread with ID ["+messageThreadID.String()+"]", payload.Message) +} + +type messageThreadHandlerNoopLogger struct{} + +var _ telemetry.Logger = (*messageThreadHandlerNoopLogger)(nil) + +func (logger *messageThreadHandlerNoopLogger) Error(_ error) {} +func (logger *messageThreadHandlerNoopLogger) WithService(_ string) telemetry.Logger { return logger } +func (logger *messageThreadHandlerNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } +func (logger *messageThreadHandlerNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { return logger } +func (logger *messageThreadHandlerNoopLogger) Trace(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Info(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Warn(_ error) {} +func (logger *messageThreadHandlerNoopLogger) Debug(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Fatal(_ error) {} +func (logger *messageThreadHandlerNoopLogger) Printf(_ string, _ ...interface{}) {} From cd09403419b2fb74c78fef1a37f8f7e4543fa2ee Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:47:40 +0300 Subject: [PATCH 08/22] style(api): format thread handler test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../handlers/message_thread_handler_test.go | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go index eac390572..dc997a69e 100644 --- a/api/pkg/handlers/message_thread_handler_test.go +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -2,8 +2,8 @@ package handlers import ( "bytes" - "encoding/json" "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -19,8 +19,8 @@ import ( "github.com/google/uuid" "github.com/palantir/stacktrace" "github.com/stretchr/testify/require" - "gorm.io/gorm" "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" ) type messageThreadHandlerRepositoryStub struct{} @@ -94,13 +94,15 @@ type messageThreadHandlerNoopLogger struct{} var _ telemetry.Logger = (*messageThreadHandlerNoopLogger)(nil) -func (logger *messageThreadHandlerNoopLogger) Error(_ error) {} -func (logger *messageThreadHandlerNoopLogger) WithService(_ string) telemetry.Logger { return logger } -func (logger *messageThreadHandlerNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } -func (logger *messageThreadHandlerNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { return logger } -func (logger *messageThreadHandlerNoopLogger) Trace(_ string) {} -func (logger *messageThreadHandlerNoopLogger) Info(_ string) {} -func (logger *messageThreadHandlerNoopLogger) Warn(_ error) {} -func (logger *messageThreadHandlerNoopLogger) Debug(_ string) {} -func (logger *messageThreadHandlerNoopLogger) Fatal(_ error) {} -func (logger *messageThreadHandlerNoopLogger) Printf(_ string, _ ...interface{}) {} +func (logger *messageThreadHandlerNoopLogger) Error(_ error) {} +func (logger *messageThreadHandlerNoopLogger) WithService(_ string) telemetry.Logger { return logger } +func (logger *messageThreadHandlerNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } +func (logger *messageThreadHandlerNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { + return logger +} +func (logger *messageThreadHandlerNoopLogger) Trace(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Info(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Warn(_ error) {} +func (logger *messageThreadHandlerNoopLogger) Debug(_ string) {} +func (logger *messageThreadHandlerNoopLogger) Fatal(_ error) {} +func (logger *messageThreadHandlerNoopLogger) Printf(_ string, _ ...interface{}) {} From ea78e6248fc0b4064bd5252ed1c609985b7f8fea Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:59:04 +0300 Subject: [PATCH 09/22] feat(web): persist opened threads as read Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- web/app/stores/threads.ts | 32 ++++ web/package.json | 1 + web/pnpm-lock.yaml | 279 +++++++++++++++++--------------- web/shared/types/api.ts | 16 +- web/tests/globals.d.ts | 28 ++++ web/tests/threads.store.test.ts | 130 +++++++++++++++ web/tests/tsconfig.json | 24 +++ 7 files changed, 376 insertions(+), 134 deletions(-) create mode 100644 web/tests/globals.d.ts create mode 100644 web/tests/threads.store.test.ts create mode 100644 web/tests/tsconfig.json diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 7312153ce..616fa952a 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -24,6 +24,13 @@ export const useThreadsStore = defineStore('threads', () => { return threads.value.find((x) => x.id === id) !== undefined } + function replaceThread(updatedThread: EntitiesMessageThread) { + const index = threads.value.findIndex( + (thread) => thread.id === updatedThread.id, + ) + if (index !== -1) threads.value[index] = updatedThread + } + async function loadThreads() { const phonesStore = usePhonesStore() if (phonesStore.owner === null && phonesStore.phones.length === 0) { @@ -87,6 +94,30 @@ export const useThreadsStore = defineStore('threads', () => { await loadThreads() } + async function markThreadRead(threadId: string, force = false) { + const thread = threads.value.find((item) => item.id === threadId) + if (!thread) throw new Error(`Cannot find thread with id ${threadId}`) + if (!force && thread.is_read) return + + try { + const response = await apiFetch<{ data: EntitiesMessageThread }>( + `/v1/message-threads/${threadId}`, + { + method: 'PUT', + body: { is_read: true }, + }, + ) + replaceThread(response.data) + } catch (error) { + notificationsStore.addNotification({ + message: 'The message thread could not be marked as read', + type: 'error', + }) + await loadThreads() + throw error + } + } + async function deleteThread(id: string) { await apiFetch(`/v1/message-threads/${id}`, { method: 'DELETE' }) threadId.value = null @@ -116,6 +147,7 @@ export const useThreadsStore = defineStore('threads', () => { setThreadId, toggleArchive, updateThread, + markThreadRead, deleteThread, resetState, } diff --git a/web/package.json b/web/package.json index ca7176277..92995d1fb 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "@commitlint/config-conventional": "^21.0.2", "@nuxt/eslint": "^1.16.0", "@nuxtjs/seo": "^5.3.2", + "@types/node": "^26.1.1", "@types/qrcode": "^1.5.6", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0934c80b2..9f9b7df23 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 2.30.1 nuxt: specifier: ^4.4.6 - version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)) @@ -77,16 +77,19 @@ importers: devDependencies: '@commitlint/cli': specifier: ^21.0.2 - version: 21.0.2(@types/node@25.9.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + version: 21.0.2(@types/node@26.1.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^21.0.2 version: 21.0.2 '@nuxt/eslint': specifier: ^1.16.0 - version: 1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxtjs/seo': specifier: ^5.3.2 - version: 5.3.2(efab421a428e7a1c2d7db08a9115738e) + version: 5.3.2(1e3685074c791d693c2c1f08b5b64b1f) + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -119,7 +122,7 @@ importers: version: 5.9.3 vuetify-nuxt-module: specifier: ^0.19.5 - version: 0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + version: 0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) packages: @@ -3330,6 +3333,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -6945,6 +6951,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -7807,11 +7816,11 @@ snapshots: '@colordx/core@5.4.3': {} - '@commitlint/cli@21.0.2(@types/node@25.9.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + '@commitlint/cli@21.0.2(@types/node@26.1.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 21.0.1 '@commitlint/lint': 21.0.2 - '@commitlint/load': 21.0.2(@types/node@25.9.1)(typescript@5.9.3) + '@commitlint/load': 21.0.2(@types/node@26.1.1)(typescript@5.9.3) '@commitlint/read': 21.0.2(conventional-commits-parser@6.4.0) '@commitlint/types': 21.0.1 tinyexec: 1.2.2 @@ -7856,14 +7865,14 @@ snapshots: '@commitlint/rules': 21.0.2 '@commitlint/types': 21.0.1 - '@commitlint/load@21.0.2(@types/node@25.9.1)(typescript@5.9.3)': + '@commitlint/load@21.0.2(@types/node@26.1.1)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 21.0.1 '@commitlint/execute-rule': 21.0.1 '@commitlint/resolve-extends': 21.0.1 '@commitlint/types': 21.0.1 cosmiconfig: 9.0.2(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.1.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) es-toolkit: 1.48.1 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -8961,19 +8970,19 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) execa: 8.0.1 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - magicast - '@nuxt/devtools-kit@4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/devtools-kit@4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) tinyexec: 1.2.4 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - magicast @@ -8988,9 +8997,9 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.1 - '@nuxt/devtools@3.2.4(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxt/devtools@3.2.4(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.2.4 '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@5.9.3)) @@ -9018,9 +9027,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.16 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - vite-plugin-vue-tracer: 1.4.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite-plugin-vue-tracer: 1.4.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) which: 6.0.1 ws: 8.21.0 transitivePeerDependencies: @@ -9069,10 +9078,10 @@ snapshots: - supports-color - typescript - '@nuxt/eslint@1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/eslint@1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@eslint/config-inspector': 3.0.4(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/eslint-config': 1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) '@nuxt/eslint-plugin': 1.16.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -9101,13 +9110,13 @@ snapshots: - utf-8-validate - vite - '@nuxt/fonts@0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/fonts@0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 - fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) h3: 1.15.11 magic-regexp: 0.10.0 ofetch: 1.5.1 @@ -9141,13 +9150,13 @@ snapshots: - uploadthing - vite - '@nuxt/icon@2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxt/icon@2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@iconify/collections': 1.0.705 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.3 '@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3)) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 local-pkg: 1.2.1 @@ -9238,7 +9247,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3)': + '@nuxt/nitro-server@4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.6(magicast@0.5.3) @@ -9256,7 +9265,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.131.0)(srvx@0.11.16) - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.6 ohash: 2.0.11 pathe: 2.0.3 @@ -9333,18 +9342,18 @@ snapshots: rc9: 3.0.1 std-env: 4.1.0 - '@nuxt/ui@4.9.0(134b0d7021e41b6e09a1785914c561db)': + '@nuxt/ui@4.9.0(dd76b1d94872d344af495ac3d3a22724)': dependencies: '@floating-ui/dom': 1.7.6 '@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3)) - '@nuxt/fonts': 0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - '@nuxt/icon': 2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@nuxt/fonts': 0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/icon': 2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) '@nuxt/kit': 4.4.8(magicast@0.5.3) '@nuxt/schema': 4.4.8 '@nuxtjs/color-mode': 4.0.1(magicast@0.5.3) '@standard-schema/spec': 1.1.0 '@tailwindcss/postcss': 4.3.2 - '@tailwindcss/vite': 4.3.2(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@tailwindcss/vite': 4.3.2(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@tanstack/vue-table': 8.21.3(vue@3.5.34(typescript@5.9.3)) '@tanstack/vue-virtual': 3.13.31(vue@3.5.34(typescript@5.9.3)) '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) @@ -9443,12 +9452,12 @@ snapshots: - vite - vue - '@nuxt/vite-builder@4.4.6(b7e6daa69be39f31d9732aca3fc8728d)': + '@nuxt/vite-builder@4.4.6(8fd0e5d6a023ec05e3f202b59dd44c54)': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) '@rollup/plugin-replace': 6.0.3(rollup@4.60.4) - '@vitejs/plugin-vue': 6.0.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.7(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) autoprefixer: 10.5.0(postcss@8.5.15) consola: 3.4.2 cssnano: 8.0.1(postcss@8.5.15) @@ -9461,7 +9470,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.6 pathe: 2.0.3 pkg-types: 2.3.1 @@ -9470,9 +9479,9 @@ snapshots: std-env: 4.1.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-node: 5.3.0(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-plugin-checker: 0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-node: 5.3.0(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-plugin-checker: 0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) vue: 3.5.34(typescript@5.9.3) vue-bundle-renderer: 2.2.0 optionalDependencies: @@ -9521,15 +9530,15 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/robots@6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxtjs/robots@6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@fingerprintjs/botd': 2.0.0 '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 h3: 1.15.11 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) pathe: 2.0.3 pkg-types: 2.3.1 ufo: 1.6.4 @@ -9540,18 +9549,18 @@ snapshots: - vite - vue - '@nuxtjs/seo@5.3.2(efab421a428e7a1c2d7db08a9115738e)': + '@nuxtjs/seo@5.3.2(1e3685074c791d693c2c1f08b5b64b1f)': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@nuxtjs/robots': 6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - '@nuxtjs/sitemap': 8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) - nuxt-link-checker: 5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxt-og-image: 6.7.2(1aec363c279050f68198006b09939f9a) - nuxt-schema-org: 6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxt-seo-utils: 8.3.1(2149e897e31419053daacf14818c8d11) - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + '@nuxtjs/robots': 6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@nuxtjs/sitemap': 8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt-link-checker: 5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt-og-image: 6.7.2(62db141a547f98b1ec791e98a10634fc) + nuxt-schema-org: 6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt-seo-utils: 8.3.1(f5f15340f9b078a0b2496ce65418c980) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -9650,14 +9659,14 @@ snapshots: - yup - zod - '@nuxtjs/sitemap@8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxtjs/sitemap@8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 fast-xml-parser: 5.9.3 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -10463,12 +10472,12 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.2 - '@tailwindcss/vite@4.3.2(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.2(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) '@tanstack/table-core@8.21.3': {} @@ -10742,6 +10751,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + '@types/qrcode@1.5.6': dependencies: '@types/node': 25.9.1 @@ -10847,19 +10860,19 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@unhead/bundler@3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@unhead/bundler@3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: - '@vitejs/devtools-kit': 0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitejs/devtools-kit': 0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) magic-string: 0.30.21 oxc-parser: 0.137.0 oxc-walker: 1.0.0(oxc-parser@0.137.0) ufo: 1.6.4 unhead: 2.1.15 - unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: esbuild: 0.25.12 lightningcss: 1.32.0 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@modelcontextprotocol/sdk' @@ -10980,7 +10993,7 @@ snapshots: - rollup - supports-color - '@vitejs/devtools-kit@0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@vitejs/devtools-kit@0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@devframes/hub': 0.5.4(devframe@0.5.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)) birpc: 4.0.0 @@ -10990,7 +11003,7 @@ snapshots: pathe: 2.0.3 perfect-debounce: 2.1.0 tinyexec: 1.2.4 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -10998,22 +11011,22 @@ snapshots: - typescript - utf-8-validate - '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@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) - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) '@vue-macros/common@3.1.2(vue@3.5.34(typescript@5.9.3))': @@ -11215,13 +11228,13 @@ snapshots: '@vueuse/metadata@14.3.0': {} - '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/metadata': 14.3.0 local-pkg: 1.2.1 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) transitivePeerDependencies: - magicast @@ -11634,9 +11647,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@26.1.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 cosmiconfig: 9.0.2(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -12401,7 +12414,7 @@ snapshots: dependencies: tiny-inflate: 1.0.3 - fontless@0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + fontless@0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: consola: 3.4.2 css-tree: 3.2.1 @@ -12417,7 +12430,7 @@ snapshots: unifont: 0.7.4 unstorage: 1.17.5(db0@0.3.4)(ioredis@5.10.1) optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -13332,7 +13345,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-link-checker@5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + nuxt-link-checker@5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) @@ -13341,9 +13354,9 @@ snapshots: fuse.js: 7.4.2 h3: 1.15.11 magic-string: 0.30.21 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -13378,7 +13391,7 @@ snapshots: - vue - zod - nuxt-og-image@6.7.2(1aec363c279050f68198006b09939f9a): + nuxt-og-image@6.7.2(62db141a547f98b1ec791e98a10634fc): dependencies: '@clack/prompts': 1.6.0 '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -13396,8 +13409,8 @@ snapshots: magic-string: 0.30.21 magicast: 0.5.3 mocked-exports: 0.1.1 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) nypm: 0.6.8 ofetch: 1.5.1 ohash: 2.0.11 @@ -13412,10 +13425,10 @@ snapshots: tinyglobby: 0.2.17 ufo: 1.6.4 ultrahtml: 1.6.0 - unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) unstorage: 1.17.5(db0@0.3.4)(ioredis@5.10.1) optionalDependencies: - fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) nitropack: 2.13.4(oxc-parser@0.131.0)(srvx@0.11.16) tailwindcss: 4.3.2 unifont: 0.7.4 @@ -13434,12 +13447,12 @@ snapshots: - vue - webpack - nuxt-schema-org@6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + nuxt-schema-org@6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) defu: 6.1.7 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) pkg-types: 2.3.1 ufo: 1.6.4 optionalDependencies: @@ -13452,20 +13465,20 @@ snapshots: - vite - vue - nuxt-seo-utils@8.3.1(2149e897e31419053daacf14818c8d11): + nuxt-seo-utils@8.3.1(f5f15340f9b078a0b2496ce65418c980): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@unhead/bundler': 3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@unhead/bundler': 3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) citty: 0.2.2 consola: 3.4.2 defu: 6.1.7 escape-string-regexp: 5.0.0 exsolve: 1.0.8 image-size: 2.0.2 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-layer-devtools: 5.3.2(e0741c0f9ace9993e5b5269790e40cb7) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-layer-devtools: 5.3.2(e9b36e1a1d2aac2bb2b60fe478e11bbb) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) pathe: 2.0.3 pkg-types: 2.3.1 scule: 1.3.0 @@ -13475,7 +13488,7 @@ snapshots: '@unhead/vue': 2.1.15(vue@3.5.34(typescript@5.9.3)) esbuild: 0.25.12 lightningcss: 1.32.0 - sharp: 0.35.3(@types/node@25.9.1) + sharp: 0.35.3(@types/node@26.1.1) unhead: 2.1.15 transitivePeerDependencies: - '@azure/app-configuration' @@ -13567,13 +13580,13 @@ snapshots: - magicast - vue - nuxt-site-config@4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + nuxt-site-config@4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.8(magicast@0.5.3) h3: 1.15.11 nuxt-site-config-kit: 4.1.1(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) pathe: 2.0.3 pkg-types: 2.3.1 site-config-stack: 4.1.1(vue@3.5.34(typescript@5.9.3)) @@ -13586,16 +13599,16 @@ snapshots: - vue - zod - nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0): + nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) '@nuxt/cli': 3.35.2(@nuxt/schema@4.4.6)(cac@6.7.14)(magicast@0.5.3) - '@nuxt/devtools': 3.2.4(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@nuxt/devtools': 3.2.4(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) '@nuxt/kit': 4.4.6(magicast@0.5.3) - '@nuxt/nitro-server': 4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3) + '@nuxt/nitro-server': 4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3) '@nuxt/schema': 4.4.6 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.6(magicast@0.5.3)) - '@nuxt/vite-builder': 4.4.6(b7e6daa69be39f31d9732aca3fc8728d) + '@nuxt/vite-builder': 4.4.6(8fd0e5d6a023ec05e3f202b59dd44c54) '@unhead/vue': 2.1.15(vue@3.5.34(typescript@5.9.3)) '@vue/shared': 3.5.34 chokidar: 5.0.0 @@ -13645,7 +13658,7 @@ snapshots: vue-router: 5.0.7(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)) optionalDependencies: '@parcel/watcher': 2.5.6 - '@types/node': 25.9.1 + '@types/node': 26.1.1 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -13716,17 +13729,17 @@ snapshots: - xml2js - yaml - nuxtseo-layer-devtools@5.3.2(e0741c0f9ace9993e5b5269790e40cb7): + nuxtseo-layer-devtools@5.3.2(e9b36e1a1d2aac2bb2b60fe478e11bbb): dependencies: '@iconify-json/carbon': 1.2.24 - '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@nuxt/ui': 4.9.0(134b0d7021e41b6e09a1785914c561db) + '@nuxt/ui': 4.9.0(dd76b1d94872d344af495ac3d3a22724) '@shikijs/langs': 4.3.1 '@shikijs/themes': 4.3.1 '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) - '@vueuse/nuxt': 14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) + '@vueuse/nuxt': 14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) ofetch: 1.5.1 shiki: 4.3.1 tailwindcss: 4.3.2 @@ -13801,16 +13814,16 @@ snapshots: - yup - zod - nuxtseo-shared@5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c): + nuxtseo-shared@5.3.2(05a95c37668c108712e1d63e07bb7c09): dependencies: '@clack/prompts': 1.6.0 - '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) '@nuxt/schema': 4.4.8 birpc: 4.0.0 consola: 3.4.2 defu: 6.1.7 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.8 ofetch: 1.5.1 pathe: 2.0.3 @@ -13821,7 +13834,7 @@ snapshots: ufo: 1.6.4 vue: 3.5.34(typescript@5.9.3) optionalDependencies: - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) transitivePeerDependencies: - magicast - vite @@ -14710,7 +14723,7 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@25.9.1): + sharp@0.35.3(@types/node@26.1.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -14741,7 +14754,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - '@types/node': 25.9.1 + '@types/node': 26.1.1 optional: true shebang-command@2.0.0: @@ -15167,6 +15180,8 @@ snapshots: undici-types@7.24.6: {} + undici-types@8.3.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -15289,7 +15304,7 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + unplugin@3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.4 @@ -15297,7 +15312,7 @@ snapshots: optionalDependencies: esbuild: 0.25.12 rollup: 4.60.4 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) unrouting@0.1.7: dependencies: @@ -15420,23 +15435,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: birpc: 2.9.0 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-node@5.3.0(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): + vite-node@5.3.0(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: cac: 6.7.14 es-module-lexer: 2.1.0 obug: 2.1.1 pathe: 2.0.3 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -15450,7 +15465,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-plugin-checker@0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 4.0.3 @@ -15460,7 +15475,7 @@ snapshots: proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 tinyglobby: 0.2.16 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vscode-uri: 3.1.0 optionalDependencies: eslint: 10.5.0(jiti@2.7.0) @@ -15469,7 +15484,7 @@ snapshots: stylelint: 17.13.0(typescript@5.9.3) typescript: 5.9.3 - vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: ansis: 4.3.0 debug: 4.4.3 @@ -15479,35 +15494,35 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-dev-rpc: 1.1.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-dev-rpc: 1.1.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.4.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + vite-plugin-vue-tracer@1.4.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) - vite-plugin-vuetify@2.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7): + vite-plugin-vuetify@2.1.3(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7): dependencies: '@vuetify/loader-shared': 2.1.2(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) debug: 4.4.3 upath: 2.0.1 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) vuetify: 4.0.7(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.34(typescript@5.9.3)) transitivePeerDependencies: - supports-color - vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): + vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -15516,7 +15531,7 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 @@ -15590,7 +15605,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vuetify-nuxt-module@0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + vuetify-nuxt-module@0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) defu: 6.1.7 @@ -15602,7 +15617,7 @@ snapshots: ufo: 1.6.4 unconfig: 0.6.1 upath: 2.0.1 - vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) + vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) vuetify: 4.0.7(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.34(typescript@5.9.3)) transitivePeerDependencies: - magicast @@ -15617,7 +15632,7 @@ snapshots: vue: 3.5.34(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 - vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) + vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) w3c-keyname@2.2.8: {} diff --git a/web/shared/types/api.ts b/web/shared/types/api.ts index df85420ad..40c5d5a85 100644 --- a/web/shared/types/api.ts +++ b/web/shared/types/api.ts @@ -61,7 +61,7 @@ export interface EntitiesBulkMessage { failed_count: number; /** @example 30 */ pending_count: number; - /** @example "bulk-csv-a1B2c3D4e5" */ + /** @example "bulk-httpsms-file.csv" */ request_id: string; /** @example 50 */ scheduled_count: number; @@ -205,6 +205,8 @@ export interface EntitiesMessageThread { id: string; /** @example false */ is_archived: boolean; + /** @example true */ + is_read: boolean; /** @example "This is a sample message content" */ last_message_content: string; /** @example "32343a19-da5e-4b1b-a767-3298a73703ca" */ @@ -479,7 +481,9 @@ export interface RequestsMessageSendScheduleWindow { export interface RequestsMessageThreadUpdate { /** @example true */ - is_archived: boolean; + is_archived?: boolean; + /** @example true */ + is_read?: boolean; } export interface RequestsPhoneAPIKeyStoreRequest { @@ -674,6 +678,14 @@ export interface ResponsesMessageSendSchedulesResponse { status: string; } +export interface ResponsesMessageThreadResponse { + data: EntitiesMessageThread; + /** @example "Request handled successfully" */ + message: string; + /** @example "success" */ + status: string; +} + export interface ResponsesMessageThreadsResponse { data: EntitiesMessageThread[]; /** @example "Request handled successfully" */ diff --git a/web/tests/globals.d.ts b/web/tests/globals.d.ts new file mode 100644 index 000000000..de43323b6 --- /dev/null +++ b/web/tests/globals.d.ts @@ -0,0 +1,28 @@ +type ApiFetch = ( + url: string, + options?: Record, +) => Promise + +declare global { + const computed: typeof import('vue').computed + const ref: typeof import('vue').ref + + function useApi(): { + apiFetch: ApiFetch + } + + function useNotificationsStore(): { + addNotification(request: { + message: string + type: 'error' | 'success' | 'info' + }): void + } + + function usePhonesStore(): { + owner: string | null + phones: Array<{ phone_number: string }> + getHeartbeat(): Promise + } +} + +export {} diff --git a/web/tests/threads.store.test.ts b/web/tests/threads.store.test.ts new file mode 100644 index 000000000..e13d50437 --- /dev/null +++ b/web/tests/threads.store.test.ts @@ -0,0 +1,130 @@ +import { strict as assert } from 'node:assert' +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 '../app/stores/threads' + +type Thread = EntitiesMessageThread & { + is_read: boolean +} + +type ThreadsStore = ReturnType & { + markThreadRead(threadId: string, force?: boolean): Promise +} + +type TestGlobals = typeof globalThis & { + ref: typeof ref + computed: typeof computed + useApi(): { + apiFetch: (url: string, options?: Record) => Promise + } + useNotificationsStore(): { + addNotification(request: { + message: string + type: 'error' | 'success' | 'info' + }): void + } + usePhonesStore(): { + owner: string | null + phones: Array<{ phone_number: string }> + getHeartbeat(): Promise + } +} + +function createThread(overrides: Partial = {}) { + return { + color: 'indigo', + contact: '+18005550100', + created_at: '2026-07-18T00:00:00Z', + id: 'thread-1', + is_archived: false, + is_read: false, + last_message_content: 'Hello', + last_message_id: 'message-1', + order_timestamp: '2026-07-18T00:00:00Z', + owner: '+18005550199', + status: 'PENDING', + updated_at: '2026-07-18T00:00:00Z', + user_id: 'user-1', + ...overrides, + } as Thread +} + +function createStoreHarness(options?: { + threads?: Thread[] + responseThread?: Thread +}) { + const calls: Array<[string, Record | undefined]> = [] + const notifications: Array<{ + message: string + type: 'error' | 'success' | 'info' + }> = [] + const responseThread = + options?.responseThread ?? createThread({ is_read: true }) + + const globals = globalThis as TestGlobals + + globals.ref = ref + globals.computed = computed + globals.useApi = () => ({ + apiFetch: async (url: string, options?: Record) => { + calls.push([url, options]) + return { data: responseThread } as T + }, + }) + globals.useNotificationsStore = () => ({ + addNotification: (request: { + message: string + type: 'error' | 'success' | 'info' + }) => { + notifications.push(request) + }, + }) + globals.usePhonesStore = () => ({ + owner: null, + phones: [], + getHeartbeat: async () => [], + }) + + setActivePinia(createPinia()) + const store = useThreadsStore() as ThreadsStore + store.threads = options?.threads ?? [createThread()] + + return { store, calls, notifications, responseThread } +} + +test('markThreadRead persists unread threads and replaces the response thread', async () => { + const updatedThread = createThread({ + is_read: true, + last_message_content: 'Updated', + }) + const { store, calls } = createStoreHarness({ responseThread: updatedThread }) + + assert.equal(typeof store.markThreadRead, 'function') + + await store.markThreadRead('thread-1') + + assert.deepEqual(calls, [ + [ + '/v1/message-threads/thread-1', + { + method: 'PUT', + body: { is_read: true }, + }, + ], + ]) + assert.deepEqual(store.threads[0], updatedThread) +}) + +test('markThreadRead skips already read threads', async () => { + const readThread = createThread({ is_read: true }) + const { store, calls } = createStoreHarness({ threads: [readThread] }) + + assert.equal(typeof store.markThreadRead, 'function') + + await store.markThreadRead('thread-1') + + assert.deepEqual(calls, []) + assert.deepEqual(store.threads[0], readThread) +}) diff --git a/web/tests/tsconfig.json b/web/tests/tsconfig.json new file mode 100644 index 000000000..482150c27 --- /dev/null +++ b/web/tests/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2022", "DOM"], + "strict": true, + "baseUrl": "..", + "paths": { + "~/*": ["./app/*"], + "~~/*": ["./*"] + }, + "types": ["node"], + "skipLibCheck": true, + "noEmit": false, + "outDir": "./.test-build" + }, + "include": [ + "./globals.d.ts", + "./threads.store.test.ts", + "../app/stores/threads.ts", + "../shared/types/api.ts" + ] +} From 88bc64d8467257d8b6b66fdb445160e2f783dee9 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:01:01 +0300 Subject: [PATCH 10/22] chore(web): keep read store change scoped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- web/package.json | 1 - web/pnpm-lock.yaml | 279 +++++++++++++++----------------- web/tests/globals.d.ts | 28 ---- web/tests/threads.store.test.ts | 130 --------------- web/tests/tsconfig.json | 24 --- 5 files changed, 132 insertions(+), 330 deletions(-) delete mode 100644 web/tests/globals.d.ts delete mode 100644 web/tests/threads.store.test.ts delete mode 100644 web/tests/tsconfig.json diff --git a/web/package.json b/web/package.json index 92995d1fb..ca7176277 100644 --- a/web/package.json +++ b/web/package.json @@ -51,7 +51,6 @@ "@commitlint/config-conventional": "^21.0.2", "@nuxt/eslint": "^1.16.0", "@nuxtjs/seo": "^5.3.2", - "@types/node": "^26.1.1", "@types/qrcode": "^1.5.6", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9f9b7df23..0934c80b2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 2.30.1 nuxt: specifier: ^4.4.6 - version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)) @@ -77,19 +77,16 @@ importers: devDependencies: '@commitlint/cli': specifier: ^21.0.2 - version: 21.0.2(@types/node@26.1.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + version: 21.0.2(@types/node@25.9.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^21.0.2 version: 21.0.2 '@nuxt/eslint': specifier: ^1.16.0 - version: 1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxtjs/seo': specifier: ^5.3.2 - version: 5.3.2(1e3685074c791d693c2c1f08b5b64b1f) - '@types/node': - specifier: ^26.1.1 - version: 26.1.1 + version: 5.3.2(efab421a428e7a1c2d7db08a9115738e) '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -122,7 +119,7 @@ importers: version: 5.9.3 vuetify-nuxt-module: specifier: ^0.19.5 - version: 0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + version: 0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) packages: @@ -3333,9 +3330,6 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -6951,9 +6945,6 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -7816,11 +7807,11 @@ snapshots: '@colordx/core@5.4.3': {} - '@commitlint/cli@21.0.2(@types/node@26.1.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + '@commitlint/cli@21.0.2(@types/node@25.9.1)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 21.0.1 '@commitlint/lint': 21.0.2 - '@commitlint/load': 21.0.2(@types/node@26.1.1)(typescript@5.9.3) + '@commitlint/load': 21.0.2(@types/node@25.9.1)(typescript@5.9.3) '@commitlint/read': 21.0.2(conventional-commits-parser@6.4.0) '@commitlint/types': 21.0.1 tinyexec: 1.2.2 @@ -7865,14 +7856,14 @@ snapshots: '@commitlint/rules': 21.0.2 '@commitlint/types': 21.0.1 - '@commitlint/load@21.0.2(@types/node@26.1.1)(typescript@5.9.3)': + '@commitlint/load@21.0.2(@types/node@25.9.1)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 21.0.1 '@commitlint/execute-rule': 21.0.1 '@commitlint/resolve-extends': 21.0.1 '@commitlint/types': 21.0.1 cosmiconfig: 9.0.2(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@26.1.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) es-toolkit: 1.48.1 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -8970,19 +8961,19 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) execa: 8.0.1 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - magicast - '@nuxt/devtools-kit@4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/devtools-kit@4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) tinyexec: 1.2.4 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - magicast @@ -8997,9 +8988,9 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.1 - '@nuxt/devtools@3.2.4(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxt/devtools@3.2.4(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.2.4 '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@5.9.3)) @@ -9027,9 +9018,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.16 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - vite-plugin-vue-tracer: 1.4.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite-plugin-vue-tracer: 1.4.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) which: 6.0.1 ws: 8.21.0 transitivePeerDependencies: @@ -9078,10 +9069,10 @@ snapshots: - supports-color - typescript - '@nuxt/eslint@1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/eslint@1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.131.0)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@eslint/config-inspector': 3.0.4(crossws@0.4.5(srvx@0.11.16))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/eslint-config': 1.16.0(@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.39)(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) '@nuxt/eslint-plugin': 1.16.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -9110,13 +9101,13 @@ snapshots: - utf-8-validate - vite - '@nuxt/fonts@0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/fonts@0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 - fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) h3: 1.15.11 magic-regexp: 0.10.0 ofetch: 1.5.1 @@ -9150,13 +9141,13 @@ snapshots: - uploadthing - vite - '@nuxt/icon@2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxt/icon@2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@iconify/collections': 1.0.705 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.3 '@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3)) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 local-pkg: 1.2.1 @@ -9247,7 +9238,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3)': + '@nuxt/nitro-server@4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.6(magicast@0.5.3) @@ -9265,7 +9256,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.131.0)(srvx@0.11.16) - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.6 ohash: 2.0.11 pathe: 2.0.3 @@ -9342,18 +9333,18 @@ snapshots: rc9: 3.0.1 std-env: 4.1.0 - '@nuxt/ui@4.9.0(dd76b1d94872d344af495ac3d3a22724)': + '@nuxt/ui@4.9.0(134b0d7021e41b6e09a1785914c561db)': dependencies: '@floating-ui/dom': 1.7.6 '@iconify/vue': 5.0.1(vue@3.5.34(typescript@5.9.3)) - '@nuxt/fonts': 0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - '@nuxt/icon': 2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@nuxt/fonts': 0.14.0(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/icon': 2.3.1(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) '@nuxt/kit': 4.4.8(magicast@0.5.3) '@nuxt/schema': 4.4.8 '@nuxtjs/color-mode': 4.0.1(magicast@0.5.3) '@standard-schema/spec': 1.1.0 '@tailwindcss/postcss': 4.3.2 - '@tailwindcss/vite': 4.3.2(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@tailwindcss/vite': 4.3.2(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@tanstack/vue-table': 8.21.3(vue@3.5.34(typescript@5.9.3)) '@tanstack/vue-virtual': 3.13.31(vue@3.5.34(typescript@5.9.3)) '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) @@ -9452,12 +9443,12 @@ snapshots: - vite - vue - '@nuxt/vite-builder@4.4.6(8fd0e5d6a023ec05e3f202b59dd44c54)': + '@nuxt/vite-builder@4.4.6(b7e6daa69be39f31d9732aca3fc8728d)': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) '@rollup/plugin-replace': 6.0.3(rollup@4.60.4) - '@vitejs/plugin-vue': 6.0.7(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) autoprefixer: 10.5.0(postcss@8.5.15) consola: 3.4.2 cssnano: 8.0.1(postcss@8.5.15) @@ -9470,7 +9461,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.6 pathe: 2.0.3 pkg-types: 2.3.1 @@ -9479,9 +9470,9 @@ snapshots: std-env: 4.1.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-node: 5.3.0(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-plugin-checker: 0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-node: 5.3.0(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-plugin-checker: 0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) vue: 3.5.34(typescript@5.9.3) vue-bundle-renderer: 2.2.0 optionalDependencies: @@ -9530,15 +9521,15 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/robots@6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxtjs/robots@6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@fingerprintjs/botd': 2.0.0 '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 h3: 1.15.11 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) pathe: 2.0.3 pkg-types: 2.3.1 ufo: 1.6.4 @@ -9549,18 +9540,18 @@ snapshots: - vite - vue - '@nuxtjs/seo@5.3.2(1e3685074c791d693c2c1f08b5b64b1f)': + '@nuxtjs/seo@5.3.2(efab421a428e7a1c2d7db08a9115738e)': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@nuxtjs/robots': 6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - '@nuxtjs/sitemap': 8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) - nuxt-link-checker: 5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxt-og-image: 6.7.2(62db141a547f98b1ec791e98a10634fc) - nuxt-schema-org: 6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxt-seo-utils: 8.3.1(f5f15340f9b078a0b2496ce65418c980) - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + '@nuxtjs/robots': 6.1.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@nuxtjs/sitemap': 8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt-link-checker: 5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt-og-image: 6.7.2(1aec363c279050f68198006b09939f9a) + nuxt-schema-org: 6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt-seo-utils: 8.3.1(2149e897e31419053daacf14818c8d11) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -9659,14 +9650,14 @@ snapshots: - yup - zod - '@nuxtjs/sitemap@8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@nuxtjs/sitemap@8.2.2(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 fast-xml-parser: 5.9.3 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -10472,12 +10463,12 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.2 - '@tailwindcss/vite@4.3.2(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.2(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) '@tanstack/table-core@8.21.3': {} @@ -10751,10 +10742,6 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/node@26.1.1': - dependencies: - undici-types: 8.3.0 - '@types/qrcode@1.5.6': dependencies: '@types/node': 25.9.1 @@ -10860,19 +10847,19 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@unhead/bundler@3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@unhead/bundler@3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: - '@vitejs/devtools-kit': 0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitejs/devtools-kit': 0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) magic-string: 0.30.21 oxc-parser: 0.137.0 oxc-walker: 1.0.0(oxc-parser@0.137.0) ufo: 1.6.4 unhead: 2.1.15 - unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: esbuild: 0.25.12 lightningcss: 1.32.0 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@modelcontextprotocol/sdk' @@ -10993,7 +10980,7 @@ snapshots: - rollup - supports-color - '@vitejs/devtools-kit@0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + '@vitejs/devtools-kit@0.3.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@devframes/hub': 0.5.4(devframe@0.5.4(crossws@0.4.5(srvx@0.11.16))(typescript@5.9.3)) birpc: 4.0.0 @@ -11003,7 +10990,7 @@ snapshots: pathe: 2.0.3 perfect-debounce: 2.1.0 tinyexec: 1.2.4 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -11011,22 +10998,22 @@ snapshots: - typescript - utf-8-validate - '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@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) - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) '@vue-macros/common@3.1.2(vue@3.5.34(typescript@5.9.3))': @@ -11228,13 +11215,13 @@ snapshots: '@vueuse/metadata@14.3.0': {} - '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': + '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/metadata': 14.3.0 local-pkg: 1.2.1 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) transitivePeerDependencies: - magicast @@ -11647,9 +11634,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@26.1.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 26.1.1 + '@types/node': 25.9.1 cosmiconfig: 9.0.2(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -12414,7 +12401,7 @@ snapshots: dependencies: tiny-inflate: 1.0.3 - fontless@0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + fontless@0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: consola: 3.4.2 css-tree: 3.2.1 @@ -12430,7 +12417,7 @@ snapshots: unifont: 0.7.4 unstorage: 1.17.5(db0@0.3.4)(ioredis@5.10.1) optionalDependencies: - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -13345,7 +13332,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-link-checker@5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + nuxt-link-checker@5.1.2(@nuxt/schema@4.4.8)(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) @@ -13354,9 +13341,9 @@ snapshots: fuse.js: 7.4.2 h3: 1.15.11 magic-string: 0.30.21 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -13391,7 +13378,7 @@ snapshots: - vue - zod - nuxt-og-image@6.7.2(62db141a547f98b1ec791e98a10634fc): + nuxt-og-image@6.7.2(1aec363c279050f68198006b09939f9a): dependencies: '@clack/prompts': 1.6.0 '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -13409,8 +13396,8 @@ snapshots: magic-string: 0.30.21 magicast: 0.5.3 mocked-exports: 0.1.1 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) nypm: 0.6.8 ofetch: 1.5.1 ohash: 2.0.11 @@ -13425,10 +13412,10 @@ snapshots: tinyglobby: 0.2.17 ufo: 1.6.4 ultrahtml: 1.6.0 - unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) unstorage: 1.17.5(db0@0.3.4)(ioredis@5.10.1) optionalDependencies: - fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + fontless: 0.2.1(db0@0.3.4)(ioredis@5.10.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) nitropack: 2.13.4(oxc-parser@0.131.0)(srvx@0.11.16) tailwindcss: 4.3.2 unifont: 0.7.4 @@ -13447,12 +13434,12 @@ snapshots: - vue - webpack - nuxt-schema-org@6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + nuxt-schema-org@6.2.3(@nuxt/schema@4.4.8)(@unhead/vue@2.1.15(vue@3.5.34(typescript@5.9.3)))(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) defu: 6.1.7 - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) pkg-types: 2.3.1 ufo: 1.6.4 optionalDependencies: @@ -13465,20 +13452,20 @@ snapshots: - vite - vue - nuxt-seo-utils@8.3.1(f5f15340f9b078a0b2496ce65418c980): + nuxt-seo-utils@8.3.1(2149e897e31419053daacf14818c8d11): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@unhead/bundler': 3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@unhead/bundler': 3.1.7(crossws@0.4.5(srvx@0.11.16))(esbuild@0.25.12)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)(unhead@2.1.15)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) citty: 0.2.2 consola: 3.4.2 defu: 6.1.7 escape-string-regexp: 5.0.0 exsolve: 1.0.8 image-size: 2.0.2 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-layer-devtools: 5.3.2(e9b36e1a1d2aac2bb2b60fe478e11bbb) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-layer-devtools: 5.3.2(e0741c0f9ace9993e5b5269790e40cb7) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) pathe: 2.0.3 pkg-types: 2.3.1 scule: 1.3.0 @@ -13488,7 +13475,7 @@ snapshots: '@unhead/vue': 2.1.15(vue@3.5.34(typescript@5.9.3)) esbuild: 0.25.12 lightningcss: 1.32.0 - sharp: 0.35.3(@types/node@26.1.1) + sharp: 0.35.3(@types/node@25.9.1) unhead: 2.1.15 transitivePeerDependencies: - '@azure/app-configuration' @@ -13580,13 +13567,13 @@ snapshots: - magicast - vue - nuxt-site-config@4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + nuxt-site-config@4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.8(magicast@0.5.3) h3: 1.15.11 nuxt-site-config-kit: 4.1.1(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) pathe: 2.0.3 pkg-types: 2.3.1 site-config-stack: 4.1.1(vue@3.5.34(typescript@5.9.3)) @@ -13599,16 +13586,16 @@ snapshots: - vue - zod - nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0): + nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) '@nuxt/cli': 3.35.2(@nuxt/schema@4.4.6)(cac@6.7.14)(magicast@0.5.3) - '@nuxt/devtools': 3.2.4(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + '@nuxt/devtools': 3.2.4(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) '@nuxt/kit': 4.4.6(magicast@0.5.3) - '@nuxt/nitro-server': 4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3) + '@nuxt/nitro-server': 4.4.6(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.131.0)(srvx@0.11.16)(typescript@5.9.3) '@nuxt/schema': 4.4.6 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.6(magicast@0.5.3)) - '@nuxt/vite-builder': 4.4.6(8fd0e5d6a023ec05e3f202b59dd44c54) + '@nuxt/vite-builder': 4.4.6(b7e6daa69be39f31d9732aca3fc8728d) '@unhead/vue': 2.1.15(vue@3.5.34(typescript@5.9.3)) '@vue/shared': 3.5.34 chokidar: 5.0.0 @@ -13658,7 +13645,7 @@ snapshots: vue-router: 5.0.7(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)) optionalDependencies: '@parcel/watcher': 2.5.6 - '@types/node': 26.1.1 + '@types/node': 25.9.1 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -13729,17 +13716,17 @@ snapshots: - xml2js - yaml - nuxtseo-layer-devtools@5.3.2(e9b36e1a1d2aac2bb2b60fe478e11bbb): + nuxtseo-layer-devtools@5.3.2(e0741c0f9ace9993e5b5269790e40cb7): dependencies: '@iconify-json/carbon': 1.2.24 - '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@nuxt/ui': 4.9.0(dd76b1d94872d344af495ac3d3a22724) + '@nuxt/ui': 4.9.0(134b0d7021e41b6e09a1785914c561db) '@shikijs/langs': 4.3.1 '@shikijs/themes': 4.3.1 '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) - '@vueuse/nuxt': 14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) - nuxtseo-shared: 5.3.2(05a95c37668c108712e1d63e07bb7c09) + '@vueuse/nuxt': 14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxtseo-shared: 5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c) ofetch: 1.5.1 shiki: 4.3.1 tailwindcss: 4.3.2 @@ -13814,16 +13801,16 @@ snapshots: - yup - zod - nuxtseo-shared@5.3.2(05a95c37668c108712e1d63e07bb7c09): + nuxtseo-shared@5.3.2(5c70ff8f2dfd69cbd6b1c828e3a5998c): dependencies: '@clack/prompts': 1.6.0 - '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) '@nuxt/schema': 4.4.8 birpc: 4.0.0 consola: 3.4.2 defu: 6.1.7 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.8 ofetch: 1.5.1 pathe: 2.0.3 @@ -13834,7 +13821,7 @@ snapshots: ufo: 1.6.4 vue: 3.5.34(typescript@5.9.3) optionalDependencies: - nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) + nuxt-site-config: 4.1.1(@nuxt/schema@4.4.8)(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.39)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@14.1.0)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(sass@1.100.0)(srvx@0.11.16)(stylelint@17.13.0(typescript@5.9.3))(terser@5.48.0)(tsx@4.22.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)) transitivePeerDependencies: - magicast - vite @@ -14723,7 +14710,7 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@26.1.1): + sharp@0.35.3(@types/node@25.9.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -14754,7 +14741,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - '@types/node': 26.1.1 + '@types/node': 25.9.1 optional: true shebang-command@2.0.0: @@ -15180,8 +15167,6 @@ snapshots: undici-types@7.24.6: {} - undici-types@8.3.0: {} - unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -15304,7 +15289,7 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + unplugin@3.3.0(esbuild@0.25.12)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.4 @@ -15312,7 +15297,7 @@ snapshots: optionalDependencies: esbuild: 0.25.12 rollup: 4.60.4 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) unrouting@0.1.7: dependencies: @@ -15435,23 +15420,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: birpc: 2.9.0 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-node@5.3.0(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): + vite-node@5.3.0(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: cac: 6.7.14 es-module-lexer: 2.1.0 obug: 2.1.1 pathe: 2.0.3 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -15465,7 +15450,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-plugin-checker@0.13.0(eslint@10.5.0(jiti@2.7.0))(meow@14.1.0)(optionator@0.9.4)(stylelint@17.13.0(typescript@5.9.3))(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 4.0.3 @@ -15475,7 +15460,7 @@ snapshots: proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 tinyglobby: 0.2.16 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vscode-uri: 3.1.0 optionalDependencies: eslint: 10.5.0(jiti@2.7.0) @@ -15484,7 +15469,7 @@ snapshots: stylelint: 17.13.0(typescript@5.9.3) typescript: 5.9.3 - vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: ansis: 4.3.0 debug: 4.4.3 @@ -15494,35 +15479,35 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - vite-dev-rpc: 1.1.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite-dev-rpc: 1.1.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.4.0(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + vite-plugin-vue-tracer@1.4.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) - vite-plugin-vuetify@2.1.3(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7): + vite-plugin-vuetify@2.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7): dependencies: '@vuetify/loader-shared': 2.1.2(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) debug: 4.4.3 upath: 2.0.1 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) vuetify: 4.0.7(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.34(typescript@5.9.3)) transitivePeerDependencies: - supports-color - vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): + vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -15531,7 +15516,7 @@ snapshots: rollup: 4.60.4 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 26.1.1 + '@types/node': 25.9.1 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 @@ -15605,7 +15590,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vuetify-nuxt-module@0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): + vuetify-nuxt-module@0.19.5(magicast@0.5.3)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)): dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) defu: 6.1.7 @@ -15617,7 +15602,7 @@ snapshots: ufo: 1.6.4 unconfig: 0.6.1 upath: 2.0.1 - vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) + vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) vuetify: 4.0.7(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.34(typescript@5.9.3)) transitivePeerDependencies: - magicast @@ -15632,7 +15617,7 @@ snapshots: vue: 3.5.34(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 - vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) + vite-plugin-vuetify: 2.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))(vuetify@4.0.7) w3c-keyname@2.2.8: {} diff --git a/web/tests/globals.d.ts b/web/tests/globals.d.ts deleted file mode 100644 index de43323b6..000000000 --- a/web/tests/globals.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -type ApiFetch = ( - url: string, - options?: Record, -) => Promise - -declare global { - const computed: typeof import('vue').computed - const ref: typeof import('vue').ref - - function useApi(): { - apiFetch: ApiFetch - } - - function useNotificationsStore(): { - addNotification(request: { - message: string - type: 'error' | 'success' | 'info' - }): void - } - - function usePhonesStore(): { - owner: string | null - phones: Array<{ phone_number: string }> - getHeartbeat(): Promise - } -} - -export {} diff --git a/web/tests/threads.store.test.ts b/web/tests/threads.store.test.ts deleted file mode 100644 index e13d50437..000000000 --- a/web/tests/threads.store.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { strict as assert } from 'node:assert' -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 '../app/stores/threads' - -type Thread = EntitiesMessageThread & { - is_read: boolean -} - -type ThreadsStore = ReturnType & { - markThreadRead(threadId: string, force?: boolean): Promise -} - -type TestGlobals = typeof globalThis & { - ref: typeof ref - computed: typeof computed - useApi(): { - apiFetch: (url: string, options?: Record) => Promise - } - useNotificationsStore(): { - addNotification(request: { - message: string - type: 'error' | 'success' | 'info' - }): void - } - usePhonesStore(): { - owner: string | null - phones: Array<{ phone_number: string }> - getHeartbeat(): Promise - } -} - -function createThread(overrides: Partial = {}) { - return { - color: 'indigo', - contact: '+18005550100', - created_at: '2026-07-18T00:00:00Z', - id: 'thread-1', - is_archived: false, - is_read: false, - last_message_content: 'Hello', - last_message_id: 'message-1', - order_timestamp: '2026-07-18T00:00:00Z', - owner: '+18005550199', - status: 'PENDING', - updated_at: '2026-07-18T00:00:00Z', - user_id: 'user-1', - ...overrides, - } as Thread -} - -function createStoreHarness(options?: { - threads?: Thread[] - responseThread?: Thread -}) { - const calls: Array<[string, Record | undefined]> = [] - const notifications: Array<{ - message: string - type: 'error' | 'success' | 'info' - }> = [] - const responseThread = - options?.responseThread ?? createThread({ is_read: true }) - - const globals = globalThis as TestGlobals - - globals.ref = ref - globals.computed = computed - globals.useApi = () => ({ - apiFetch: async (url: string, options?: Record) => { - calls.push([url, options]) - return { data: responseThread } as T - }, - }) - globals.useNotificationsStore = () => ({ - addNotification: (request: { - message: string - type: 'error' | 'success' | 'info' - }) => { - notifications.push(request) - }, - }) - globals.usePhonesStore = () => ({ - owner: null, - phones: [], - getHeartbeat: async () => [], - }) - - setActivePinia(createPinia()) - const store = useThreadsStore() as ThreadsStore - store.threads = options?.threads ?? [createThread()] - - return { store, calls, notifications, responseThread } -} - -test('markThreadRead persists unread threads and replaces the response thread', async () => { - const updatedThread = createThread({ - is_read: true, - last_message_content: 'Updated', - }) - const { store, calls } = createStoreHarness({ responseThread: updatedThread }) - - assert.equal(typeof store.markThreadRead, 'function') - - await store.markThreadRead('thread-1') - - assert.deepEqual(calls, [ - [ - '/v1/message-threads/thread-1', - { - method: 'PUT', - body: { is_read: true }, - }, - ], - ]) - assert.deepEqual(store.threads[0], updatedThread) -}) - -test('markThreadRead skips already read threads', async () => { - const readThread = createThread({ is_read: true }) - const { store, calls } = createStoreHarness({ threads: [readThread] }) - - assert.equal(typeof store.markThreadRead, 'function') - - await store.markThreadRead('thread-1') - - assert.deepEqual(calls, []) - assert.deepEqual(store.threads[0], readThread) -}) diff --git a/web/tests/tsconfig.json b/web/tests/tsconfig.json deleted file mode 100644 index 482150c27..000000000 --- a/web/tests/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", - "lib": ["ES2022", "DOM"], - "strict": true, - "baseUrl": "..", - "paths": { - "~/*": ["./app/*"], - "~~/*": ["./*"] - }, - "types": ["node"], - "skipLibCheck": true, - "noEmit": false, - "outDir": "./.test-build" - }, - "include": [ - "./globals.d.ts", - "./threads.store.test.ts", - "../app/stores/threads.ts", - "../shared/types/api.ts" - ] -} From de378990cea82b7c2ca885684d69f2a0805eb25d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:04:36 +0300 Subject: [PATCH 11/22] fix(web): preserve thread update failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- web/app/stores/threads.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 616fa952a..362c8d621 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -113,7 +113,15 @@ export const useThreadsStore = defineStore('threads', () => { message: 'The message thread could not be marked as read', type: 'error', }) - await loadThreads() + try { + await loadThreads() + } catch (reloadError) { + throw new AggregateError( + [error, reloadError], + 'Could not mark the message thread as read or reload threads', + { cause: reloadError }, + ) + } throw error } } From dca776222c78e276538a8bfc7dcfd8091e1f5404 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:14:53 +0300 Subject: [PATCH 12/22] feat(web): highlight unread message threads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- web/app/components/MessageThread.vue | 10 +++++++++- web/app/pages/threads/[id]/index.vue | 21 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 56e1d7c0f..2ced140d2 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -96,6 +96,7 @@ function onInstallApp() { :key="thread.id" :to="{ name: 'threads-id', params: { id: thread.id } }" :active="threadsStore.threadId === thread.id" + :class="{ 'message-thread--unread': !thread.is_read }" > - {{ + {{ formatPhoneNumber(thread.contact) }} {{ thread.last_message_content }} @@ -158,3 +160,9 @@ function onInstallApp() { + + diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 541a0d3a5..fdcac3f75 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -112,9 +112,19 @@ function scrollToElement() { hideMessages.value = false } +async function markCurrentThreadRead(force = false) { + const threadId = route.params.id as string + try { + await threadsStore.markThreadRead(threadId, force) + } catch (error) { + console.error(error) + } +} + function loadMessages(hide = true) { loadingMessages.value = true const threadId = route.params.id as string + void markCurrentThreadRead() threadsStore .loadThreadMessages(threadId) .then((response: EntitiesMessage[]) => { @@ -236,7 +246,16 @@ onMounted(async () => { if (!loadingMessages.value) loadMessages(false) }) webhookChannel.bind('message.phone.received', () => { - if (!loadingMessages.value) loadMessages(false) + if (!loadingMessages.value) { + void markCurrentThreadRead(true) + loadMessages(false) + } + }) + webhookChannel.bind('message.call.missed', () => { + if (!loadingMessages.value) { + void markCurrentThreadRead(true) + loadMessages(false) + } }) }) From 24fef0b641ea41d3fbc0d8c0aacbb00ff0ff9ea9 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:24:27 +0300 Subject: [PATCH 13/22] test: cover message thread read receipts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- tests/README.md | 1 + tests/read_receipts_test.go | 192 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tests/read_receipts_test.go diff --git a/tests/README.md b/tests/README.md index a37653e53..1e586742c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -53,6 +53,7 @@ The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect al - [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status - [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint +- [x] **Message thread read receipts E2E** — Incoming SMS and missed calls mark a thread unread, the existing thread update endpoint marks it read, and outbound activity preserves unread state ## Prerequisites diff --git a/tests/read_receipts_test.go b/tests/read_receipts_test.go new file mode 100644 index 000000000..c680d3ea3 --- /dev/null +++ b/tests/read_receipts_test.go @@ -0,0 +1,192 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationMessageThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + IsRead bool `json:"is_read"` + LastMessageContent *string `json:"last_message_content"` +} + +func requestJSON( + ctx context.Context, + t *testing.T, + method string, + path string, + apiKey string, + payload any, + expectedStatus int, + output any, +) { + t.Helper() + + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + + request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, body) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) + + if output != nil { + require.NoError(t, json.Unmarshal(responseBody, output)) + } +} + +func fetchMessageThreads(ctx context.Context, t *testing.T, owner string) []integrationMessageThread { + t.Helper() + + var response struct { + Data []integrationMessageThread `json:"data"` + } + path := fmt.Sprintf( + "/v1/message-threads?owner=%s&skip=0&limit=20&is_archived=false", + url.QueryEscape(owner), + ) + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) + return response.Data +} + +func waitForMessageThread( + ctx context.Context, + t *testing.T, + owner string, + contact string, + timeout time.Duration, + matches func(integrationMessageThread) bool, +) integrationMessageThread { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, thread := range fetchMessageThreads(ctx, t, owner) { + if thread.Contact == contact && matches(thread) { + return thread + } + } + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("thread %s -> %s did not reach the expected state within %v", owner, contact, timeout) + return integrationMessageThread{} +} + +func markMessageThreadRead(ctx context.Context, t *testing.T, threadID string) integrationMessageThread { + t.Helper() + + var response struct { + Data integrationMessageThread `json:"data"` + } + requestJSON( + ctx, + t, + http.MethodPut, + "/v1/message-threads/"+threadID, + userAPIKey, + map[string]any{"is_read": true}, + http.StatusOK, + &response, + ) + return response.Data +} + +func TestMessageThreadReadReceipts(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + contact := randomPhoneNumber() + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/receive", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "content": "Unread inbound message", + "encrypted": false, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + http.StatusOK, + nil, + ) + + thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return !thread.IsRead + }) + assert.False(t, thread.IsRead) + + updated := markMessageThreadRead(ctx, t, thread.ID) + assert.True(t, updated.IsRead) + waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool { + return thread.IsRead + }) + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/calls/missed", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + http.StatusOK, + nil, + ) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return !thread.IsRead && + thread.LastMessageContent != nil && + *thread.LastMessageContent == "Missed phone call" + }) + assert.False(t, thread.IsRead) + + outboundContent := "Outbound activity preserves unread" + client := newAPIClient() + _, response, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: outboundContent, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return thread.LastMessageContent != nil && + *thread.LastMessageContent == outboundContent + }) + assert.False(t, thread.IsRead, "outbound activity must not clear unread state") +} From 3fc71ba174fc3398d21b64aa733ef884a3f47e11 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:26:59 +0300 Subject: [PATCH 14/22] test: preserve event timestamp precision Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- tests/read_receipts_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/read_receipts_test.go b/tests/read_receipts_test.go index c680d3ea3..2f11b4b3f 100644 --- a/tests/read_receipts_test.go +++ b/tests/read_receipts_test.go @@ -134,7 +134,7 @@ func TestMessageThreadReadReceipts(t *testing.T) { "content": "Unread inbound message", "encrypted": false, "sim": "SIM1", - "timestamp": time.Now().UTC().Format(time.RFC3339), + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), }, http.StatusOK, nil, @@ -161,7 +161,7 @@ func TestMessageThreadReadReceipts(t *testing.T) { "from": contact, "to": phone.PhoneNumber, "sim": "SIM1", - "timestamp": time.Now().UTC().Format(time.RFC3339), + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), }, http.StatusOK, nil, From c7482d905d25533c7819963dd250c38e96382f0a Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:52:00 +0300 Subject: [PATCH 15/22] fix(api): persist new unread threads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../gorm_message_thread_repository.go | 19 +++- .../gorm_message_thread_repository_test.go | 94 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index 4fde3b4e9..adadea035 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -111,7 +111,24 @@ func (repository *gormMessageThreadRepository) Store(ctx context.Context, thread ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(thread).Error; err != nil { + isRead := thread.IsRead + err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(thread) + thread.IsRead = isRead + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 || isRead { + return nil + } + + return tx.Model(&entities.MessageThread{}). + Where("user_id = ?", thread.UserID). + Where("id = ?", thread.ID). + UpdateColumn("is_read", false). + Error + }) + if err != nil { msg := fmt.Sprintf("cannot save message thread with ID [%s]", thread.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go index 10a13b077..8143f03cf 100644 --- a/api/pkg/repositories/gorm_message_thread_repository_test.go +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -1,14 +1,108 @@ package repositories import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "strings" "testing" "time" "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" + "go.opentelemetry.io/otel/trace" + "gorm.io/driver/postgres" + "gorm.io/gorm" ) +type messageThreadTestStatement struct { + query string + args []any +} + +type messageThreadTestConnPool struct { + statements []messageThreadTestStatement +} + +func (messageThreadTestConnPool) PrepareContext(context.Context, string) (*sql.Stmt, error) { + return nil, errors.New("unexpected PrepareContext") +} + +func (pool *messageThreadTestConnPool) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) { + pool.statements = append(pool.statements, messageThreadTestStatement{ + query: query, + args: append([]any(nil), args...), + }) + return driver.RowsAffected(1), nil +} + +func (messageThreadTestConnPool) QueryContext(context.Context, string, ...any) (*sql.Rows, error) { + return nil, errors.New("unexpected QueryContext") +} + +func (messageThreadTestConnPool) QueryRowContext(context.Context, string, ...any) *sql.Row { + return &sql.Row{} +} + +func (pool *messageThreadTestConnPool) BeginTx(context.Context, *sql.TxOptions) (gorm.ConnPool, error) { + return pool, nil +} + +func (*messageThreadTestConnPool) Commit() error { + return nil +} + +func (*messageThreadTestConnPool) Rollback() error { + return nil +} + +type messageThreadTestLogger struct{} + +func (logger *messageThreadTestLogger) Error(error) {} +func (logger *messageThreadTestLogger) WithService(string) telemetry.Logger { return logger } + +func (logger *messageThreadTestLogger) WithString(string, string) telemetry.Logger { return logger } + +func (logger *messageThreadTestLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *messageThreadTestLogger) Trace(string) {} +func (logger *messageThreadTestLogger) Info(string) {} +func (logger *messageThreadTestLogger) Warn(error) {} +func (logger *messageThreadTestLogger) Debug(string) {} +func (logger *messageThreadTestLogger) Fatal(error) {} +func (logger *messageThreadTestLogger) Printf(string, ...interface{}) {} + +func TestMessageThreadStorePreservesExplicitUnreadState(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 := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db) + thread := &entities.MessageThread{ + ID: uuid.New(), + IsRead: false, + } + + require.NoError(t, repository.Store(context.Background(), thread)) + assert.False(t, thread.IsRead) + + require.NotEmpty(t, pool.statements) + update := pool.statements[len(pool.statements)-1] + assert.True(t, strings.HasPrefix(update.query, `UPDATE "message_threads"`)) + assert.Contains(t, update.query, `"is_read"=$1`) + assert.Contains(t, update.args, false) +} + func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { messageID := uuid.New() updates := messageThreadActivityUpdates(MessageThreadActivityUpdate{ From 4544219403139218a23d5cdc12a634f54a8836ca Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 11:59:45 +0300 Subject: [PATCH 16/22] style(api): apply gofumpt to handler test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/pkg/handlers/message_thread_handler_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go index dc997a69e..9f390b3d7 100644 --- a/api/pkg/handlers/message_thread_handler_test.go +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -94,9 +94,11 @@ type messageThreadHandlerNoopLogger struct{} var _ telemetry.Logger = (*messageThreadHandlerNoopLogger)(nil) -func (logger *messageThreadHandlerNoopLogger) Error(_ error) {} -func (logger *messageThreadHandlerNoopLogger) WithService(_ string) telemetry.Logger { return logger } +func (logger *messageThreadHandlerNoopLogger) Error(_ error) {} +func (logger *messageThreadHandlerNoopLogger) WithService(_ string) telemetry.Logger { return logger } + func (logger *messageThreadHandlerNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } + func (logger *messageThreadHandlerNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { return logger } From 879230b74b7114dba981fe8d5a7efc126eef1cd8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 12:15:04 +0300 Subject: [PATCH 17/22] test(api): update listener service setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/pkg/listeners/message_thread_listener_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pkg/listeners/message_thread_listener_test.go b/api/pkg/listeners/message_thread_listener_test.go index be6e3ea18..ef277c284 100644 --- a/api/pkg/listeners/message_thread_listener_test.go +++ b/api/pkg/listeners/message_thread_listener_test.go @@ -66,7 +66,7 @@ func newMessageThreadListenerForTest() (*listenerMessageThreadRepository, map[st repository := &listenerMessageThreadRepository{} logger := &noopListenerLogger{} tracer := telemetry.NewOtelLogger("test", logger) - service := services.NewMessageThreadService(logger, tracer, repository, nil) + service := services.NewMessageThreadService(logger, tracer, repository, nil, nil) _, routes := NewMessageThreadListener(logger, tracer, service) return repository, routes } From a5451ea7e0c65002b0c297e2524f9ba3a125c107 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 13:03:49 +0300 Subject: [PATCH 18/22] fix: harden read receipt synchronization Avoid duplicate realtime read updates and the post-write reload race. Retry asynchronous phone-key binding in integration tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/pkg/services/message_thread_service.go | 18 +++-- .../services/message_thread_service_test.go | 10 ++- .../plans/2026-07-18-read-receipts.md | 69 ++++++++++++------- tests/read_receipts_test.go | 41 +++++++---- web/app/pages/threads/[id]/index.vue | 8 +-- 5 files changed, 97 insertions(+), 49 deletions(-) diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index f00e2062c..d7345043c 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -145,18 +145,28 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me ctx, span := service.tracer.Start(ctx) defer span.End() + thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) + if err != nil { + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread with id [%s]", params.MessageThreadID)) + } + update := repositories.MessageThreadStatusUpdate{ IsArchived: params.IsArchived, IsRead: params.IsRead, ReadAt: time.Now().UTC(), } - if err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { + if err = service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot update message thread with id [%s]", params.MessageThreadID)) } - thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) - if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread with id [%s]", params.MessageThreadID)) + if params.IsArchived != nil { + thread.IsArchived = *params.IsArchived + } + if params.IsRead != nil { + thread.IsRead = *params.IsRead + if *params.IsRead { + thread.LastReadAt = update.ReadAt + } } return thread, nil diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go index 61bdeebcf..556e9792a 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -179,14 +179,17 @@ func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { threadID := uuid.New() - isRead := true + isRead := false var captured repositories.MessageThreadStatusUpdate + var calls []string repository := &messageThreadRepositoryStub{ updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) error { + calls = append(calls, "update") captured = params return nil }, load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + calls = append(calls, "load") return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: true}, nil }, } @@ -203,10 +206,15 @@ func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { assert.Same(t, &isRead, captured.IsRead) assert.False(t, captured.ReadAt.IsZero()) assert.True(t, thread.IsArchived) + assert.False(t, thread.IsRead) + assert.Equal(t, []string{"load", "update"}, calls) } func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { repository := &messageThreadRepositoryStub{ + load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New()}, nil + }, updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") }, diff --git a/docs/superpowers/plans/2026-07-18-read-receipts.md b/docs/superpowers/plans/2026-07-18-read-receipts.md index 9a73ea748..a39af399a 100644 --- a/docs/superpowers/plans/2026-07-18-read-receipts.md +++ b/docs/superpowers/plans/2026-07-18-read-receipts.md @@ -26,6 +26,7 @@ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ``` + - Add end-to-end coverage in the existing `tests/` integration project. ## File Structure @@ -61,6 +62,7 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 1: Add the read-state API contract **Files:** + - Modify: `api/pkg/entities/message_thread.go` - Create: `api/pkg/entities/message_thread_test.go` - Modify: `api/pkg/requests/message_thread_update_request.go` @@ -71,6 +73,7 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 - Modify: `api/pkg/services/message_thread_service.go` **Interfaces:** + - Produces: `MessageThread.IsRead bool`, `MessageThread.LastReadAt time.Time` - Produces: `MessageThreadUpdate.IsArchived *bool`, `MessageThreadUpdate.IsRead *bool` - Produces: `MessageThreadStatusParams.IsArchived *bool`, `MessageThreadStatusParams.IsRead *bool` @@ -276,11 +279,13 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 2: Add atomic thread persistence operations **Files:** + - Modify: `api/pkg/repositories/message_thread_repository.go` - Modify: `api/pkg/repositories/gorm_message_thread_repository.go` - Create: `api/pkg/repositories/gorm_message_thread_repository_test.go` **Interfaces:** + - Produces: `repositories.MessageThreadActivityUpdate` - Produces: `repositories.MessageThreadStatusUpdate` - Produces: `repositories.MessageThreadDeletedUpdate` @@ -545,6 +550,7 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 3: Apply read rules in services and listeners **Files:** + - Modify: `api/pkg/services/message_thread_service.go` - Create: `api/pkg/services/message_thread_service_test.go` - Modify: `api/pkg/listeners/message_thread_listener.go` @@ -554,6 +560,7 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 - Create: `api/pkg/listeners/websocket_listener_test.go` **Interfaces:** + - Consumes: repository update types and methods from Task 2. - Produces: `MessageThreadUpdateParams.MarksUnread bool` - Produces: `MessageThreadUpdateParams.EventTimestamp time.Time` @@ -1125,12 +1132,14 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 4: Finish the HTTP behavior and regenerate Swagger **Files:** + - Modify: `api/pkg/handlers/message_thread_handler.go` - Modify: `api/docs/docs.go` - Modify: `api/docs/swagger.json` - Modify: `api/docs/swagger.yaml` **Interfaces:** + - Consumes: `responses.MessageThreadResponse` from Task 1. - Produces: 404 behavior for missing thread updates. - Produces: Swagger schema fields `is_read`, optional `is_archived`, optional `is_read`. @@ -1198,10 +1207,12 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 5: Add automatic read updates to the web store **Files:** + - Modify: `web/shared/types/api.ts` - Modify: `web/app/stores/threads.ts` **Interfaces:** + - Consumes: API `PUT /v1/message-threads/{id}` with `{ is_read: true }`. - Produces: `markThreadRead(threadId: string, force?: boolean): Promise`. @@ -1222,8 +1233,10 @@ In `web/app/stores/threads.ts`, add: ```ts function replaceThread(updatedThread: EntitiesMessageThread) { - const index = threads.value.findIndex((thread) => thread.id === updatedThread.id) - if (index !== -1) threads.value[index] = updatedThread + const index = threads.value.findIndex( + (thread) => thread.id === updatedThread.id, + ); + if (index !== -1) threads.value[index] = updatedThread; } ``` @@ -1233,26 +1246,26 @@ Add: ```ts async function markThreadRead(threadId: string, force = false) { - const thread = threads.value.find((item) => item.id === threadId) - if (!thread) throw new Error(`Cannot find thread with id ${threadId}`) - if (!force && thread.is_read) return + const thread = threads.value.find((item) => item.id === threadId); + if (!thread) throw new Error(`Cannot find thread with id ${threadId}`); + if (!force && thread.is_read) return; try { const response = await apiFetch<{ data: EntitiesMessageThread }>( `/v1/message-threads/${threadId}`, { - method: 'PUT', + method: "PUT", body: { is_read: true }, }, - ) - replaceThread(response.data) + ); + replaceThread(response.data); } catch (error) { notificationsStore.addNotification({ - message: 'The message thread could not be marked as read', - type: 'error', - }) - await loadThreads() - throw error + message: "The message thread could not be marked as read", + type: "error", + }); + await loadThreads(); + throw error; } } ``` @@ -1293,10 +1306,12 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 6: Mark opened threads read and highlight unread threads **Files:** + - Modify: `web/app/pages/threads/[id]/index.vue` - Modify: `web/app/components/MessageThread.vue` **Interfaces:** + - Consumes: `threadsStore.markThreadRead(threadId, force)`. - Produces: automatic initial and realtime read updates. - Produces: shared unread list styling on mobile and desktop. @@ -1307,11 +1322,11 @@ In `web/app/pages/threads/[id]/index.vue`, add: ```ts async function markCurrentThreadRead(force = false) { - const threadId = route.params.id as string + const threadId = route.params.id as string; try { - await threadsStore.markThreadRead(threadId, force) + await threadsStore.markThreadRead(threadId, force); } catch (error) { - console.error(error) + console.error(error); } } ``` @@ -1319,7 +1334,7 @@ async function markCurrentThreadRead(force = false) { At the start of `loadMessages`, after computing `threadId`, call without awaiting: ```ts -void markCurrentThreadRead() +void markCurrentThreadRead(); ``` This lets message loading continue even if the read update fails. @@ -1329,23 +1344,23 @@ This lets message loading continue even if the read update fails. Change the incoming SMS binding: ```ts -webhookChannel.bind('message.phone.received', () => { +webhookChannel.bind("message.phone.received", () => { if (!loadingMessages.value) { - void markCurrentThreadRead(true) - loadMessages(false) + void markCurrentThreadRead(true); + loadMessages(false); } -}) +}); ``` Add the missed-call binding with the same behavior: ```ts -webhookChannel.bind('message.call.missed', () => { +webhookChannel.bind("message.call.missed", () => { if (!loadingMessages.value) { - void markCurrentThreadRead(true) - loadMessages(false) + void markCurrentThreadRead(true); + loadMessages(false); } -}) +}); ``` The forced call is required because the local thread may still say `is_read: @@ -1409,10 +1424,12 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 7: Add integration coverage **Files:** + - Create: `tests/read_receipts_test.go` - Modify: `tests/README.md` **Interfaces:** + - Consumes: thread index and update HTTP endpoints from Tasks 1-4. - Consumes: incoming SMS, missed-call, and outbound message flows from Task 3. - Produces: `TestMessageThreadReadReceipts`. @@ -1670,9 +1687,11 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 ### Task 8: Verify the complete feature **Files:** + - Review: all files changed since `origin/main` **Interfaces:** + - Consumes: all previous tasks. - Produces: a clean, verified feature branch. diff --git a/tests/read_receipts_test.go b/tests/read_receipts_test.go index 2f11b4b3f..f00035474 100644 --- a/tests/read_receipts_test.go +++ b/tests/read_receipts_test.go @@ -35,28 +35,39 @@ func requestJSON( ) { t.Helper() - var body io.Reader + var encoded []byte if payload != nil { - encoded, err := json.Marshal(payload) + var err error + encoded, err = json.Marshal(payload) require.NoError(t, err) - body = bytes.NewReader(encoded) } - request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, body) - require.NoError(t, err) - request.Header.Set("x-api-key", apiKey) - request.Header.Set("Content-Type", "application/json") + deadline := time.Now().Add(20 * time.Second) + for { + request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, bytes.NewReader(encoded)) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + request.Header.Set("Content-Type", "application/json") - response, err := http.DefaultClient.Do(request) - require.NoError(t, err) - defer response.Body.Close() + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) - responseBody, err := io.ReadAll(response.Body) - require.NoError(t, err) - require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) + responseBody, err := io.ReadAll(response.Body) + response.Body.Close() + require.NoError(t, err) + + if response.StatusCode == http.StatusUnauthorized && + apiKey != userAPIKey && + time.Now().Before(deadline) { + time.Sleep(500 * time.Millisecond) + continue + } - if output != nil { - require.NoError(t, json.Unmarshal(responseBody, output)) + require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) + if output != nil { + require.NoError(t, json.Unmarshal(responseBody, output)) + } + return } } diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 1ca2c334f..73632a64c 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -121,10 +121,10 @@ async function markCurrentThreadRead(force = false) { } } -function loadMessages(hide = true) { +function loadMessages(hide = true, markRead = true) { loadingMessages.value = true const threadId = route.params.id as string - void markCurrentThreadRead() + if (markRead) void markCurrentThreadRead() threadsStore .loadThreadMessages(threadId) .then((response: EntitiesMessage[]) => { @@ -244,13 +244,13 @@ onMounted(async () => { webhookChannel.bind('message.phone.received', () => { if (!loadingMessages.value) { void markCurrentThreadRead(true) - loadMessages(false) + loadMessages(false, false) } }) webhookChannel.bind('message.call.missed', () => { if (!loadingMessages.value) { void markCurrentThreadRead(true) - loadMessages(false) + loadMessages(false, false) } }) }) From b5a7db2b99e8c81ac4396ccd5d9cffd5835106cc Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 13:06:07 +0300 Subject: [PATCH 19/22] docs: normalize plan code indentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../plans/2026-07-18-read-receipts.md | 1368 ++++++++--------- 1 file changed, 684 insertions(+), 684 deletions(-) diff --git a/docs/superpowers/plans/2026-07-18-read-receipts.md b/docs/superpowers/plans/2026-07-18-read-receipts.md index a39af399a..1887a1511 100644 --- a/docs/superpowers/plans/2026-07-18-read-receipts.md +++ b/docs/superpowers/plans/2026-07-18-read-receipts.md @@ -87,27 +87,27 @@ Create `api/pkg/entities/message_thread_test.go`: package entities import ( - "reflect" - "testing" + "reflect" + "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMessageThreadReadFieldsHaveBackwardCompatibleDefaults(t *testing.T) { - threadType := reflect.TypeOf(MessageThread{}) + threadType := reflect.TypeOf(MessageThread{}) - isRead, ok := threadType.FieldByName("IsRead") - require.True(t, ok) - assert.Contains(t, isRead.Tag.Get("gorm"), "not null") - assert.Contains(t, isRead.Tag.Get("gorm"), "default:true") - assert.Equal(t, "is_read", isRead.Tag.Get("json")) + isRead, ok := threadType.FieldByName("IsRead") + require.True(t, ok) + assert.Contains(t, isRead.Tag.Get("gorm"), "not null") + assert.Contains(t, isRead.Tag.Get("gorm"), "default:true") + assert.Equal(t, "is_read", isRead.Tag.Get("json")) - lastReadAt, ok := threadType.FieldByName("LastReadAt") - require.True(t, ok) - assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null") - assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP") - assert.Equal(t, "-", lastReadAt.Tag.Get("json")) + lastReadAt, ok := threadType.FieldByName("LastReadAt") + require.True(t, ok) + assert.Contains(t, lastReadAt.Tag.Get("gorm"), "not null") + assert.Contains(t, lastReadAt.Tag.Get("gorm"), "default:CURRENT_TIMESTAMP") + assert.Equal(t, "-", lastReadAt.Tag.Get("json")) } ``` @@ -119,27 +119,27 @@ Create `api/pkg/requests/message_thread_update_request_test.go`: package requests import ( - "testing" + "testing" - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" ) func TestMessageThreadUpdateToUpdateParamsPreservesOptionalFields(t *testing.T) { - threadID := uuid.New() - isRead := true - input := MessageThreadUpdate{ - MessageThreadID: threadID.String(), - IsRead: &isRead, - } + threadID := uuid.New() + isRead := true + input := MessageThreadUpdate{ + MessageThreadID: threadID.String(), + IsRead: &isRead, + } - params := input.ToUpdateParams(entities.UserID("user-id")) + params := input.ToUpdateParams(entities.UserID("user-id")) - assert.Equal(t, threadID, params.MessageThreadID) - assert.Equal(t, entities.UserID("user-id"), params.UserID) - assert.Nil(t, params.IsArchived) - assert.Same(t, &isRead, params.IsRead) + assert.Equal(t, threadID, params.MessageThreadID) + assert.Equal(t, entities.UserID("user-id"), params.UserID) + assert.Nil(t, params.IsArchived) + assert.Same(t, &isRead, params.IsRead) } ``` @@ -149,36 +149,36 @@ Create `api/pkg/validators/message_thread_handler_validator_test.go`: package validators import ( - "context" - "testing" + "context" + "testing" - "github.com/NdoleStudio/httpsms/pkg/requests" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" ) func TestValidateUpdateRequiresAtLeastOneStatusField(t *testing.T) { - validator := &MessageThreadHandlerValidator{} - request := requests.MessageThreadUpdate{ - MessageThreadID: uuid.NewString(), - } + validator := &MessageThreadHandlerValidator{} + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + } - errors := validator.ValidateUpdate(context.Background(), request) + errors := validator.ValidateUpdate(context.Background(), request) - assert.NotEmpty(t, errors.Get("payload")) + assert.NotEmpty(t, errors.Get("payload")) } func TestValidateUpdateAcceptsReadOnlyUpdate(t *testing.T) { - validator := &MessageThreadHandlerValidator{} - isRead := true - request := requests.MessageThreadUpdate{ - MessageThreadID: uuid.NewString(), - IsRead: &isRead, - } + validator := &MessageThreadHandlerValidator{} + isRead := true + request := requests.MessageThreadUpdate{ + MessageThreadID: uuid.NewString(), + IsRead: &isRead, + } - errors := validator.ValidateUpdate(context.Background(), request) + errors := validator.ValidateUpdate(context.Background(), request) - assert.Empty(t, errors) + assert.Empty(t, errors) } ``` @@ -206,20 +206,20 @@ Change `requests.MessageThreadUpdate` and its conversion: ```go type MessageThreadUpdate struct { - request - IsArchived *bool `json:"is_archived,omitempty" example:"true"` - IsRead *bool `json:"is_read,omitempty" example:"true"` + request + IsArchived *bool `json:"is_archived,omitempty" example:"true"` + IsRead *bool `json:"is_read,omitempty" example:"true"` - MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` + MessageThreadID string `json:"messageThreadID" swaggerignore:"true"` } func (input *MessageThreadUpdate) ToUpdateParams(userID entities.UserID) services.MessageThreadStatusParams { - return services.MessageThreadStatusParams{ - UserID: userID, - MessageThreadID: uuid.MustParse(input.MessageThreadID), - IsArchived: input.IsArchived, - IsRead: input.IsRead, - } + return services.MessageThreadStatusParams{ + UserID: userID, + MessageThreadID: uuid.MustParse(input.MessageThreadID), + IsArchived: input.IsArchived, + IsRead: input.IsRead, + } } ``` @@ -227,10 +227,10 @@ Change the service parameter type: ```go type MessageThreadStatusParams struct { - IsArchived *bool - IsRead *bool - UserID entities.UserID - MessageThreadID uuid.UUID + IsArchived *bool + IsRead *bool + UserID entities.UserID + MessageThreadID uuid.UUID } ``` @@ -239,7 +239,7 @@ After `v.ValidateStruct()` in `ValidateUpdate`, add an explicit payload check: ```go errors := v.ValidateStruct() if request.IsArchived == nil && request.IsRead == nil { - errors.Add("payload", "at least one of is_archived or is_read is required") + errors.Add("payload", "at least one of is_archived or is_read is required") } return errors ``` @@ -249,8 +249,8 @@ Add a single-thread response beside `MessageThreadsResponse`: ```go // MessageThreadResponse is the payload containing entities.MessageThread type MessageThreadResponse struct { - response - Data entities.MessageThread `json:"data"` + response + Data entities.MessageThread `json:"data"` } ``` @@ -300,60 +300,60 @@ Create `api/pkg/repositories/gorm_message_thread_repository_test.go`: package repositories import ( - "testing" - "time" + "testing" + "time" - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" ) func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { - messageID := uuid.New() - updates := messageThreadActivityUpdates(MessageThreadActivityUpdate{ - Timestamp: time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), - MessageID: messageID, - Content: "hello", - Status: entities.MessageStatusReceived, - }) - - assert.Equal(t, map[string]any{ - "order_timestamp": time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), - "last_message_id": messageID, - "last_message_content": "hello", - "status": entities.MessageStatusReceived, - }, updates) - assert.NotContains(t, updates, "is_read") - assert.NotContains(t, updates, "is_archived") - assert.NotContains(t, updates, "last_read_at") + messageID := uuid.New() + updates := messageThreadActivityUpdates(MessageThreadActivityUpdate{ + Timestamp: time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + MessageID: messageID, + Content: "hello", + Status: entities.MessageStatusReceived, + }) + + assert.Equal(t, map[string]any{ + "order_timestamp": time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), + "last_message_id": messageID, + "last_message_content": "hello", + "status": entities.MessageStatusReceived, + }, updates) + assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "is_archived") + assert.NotContains(t, updates, "last_read_at") } func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) { - isRead := true - readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC) + isRead := true + readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC) - updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ - IsRead: &isRead, - ReadAt: readAt, - }) + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsRead: &isRead, + ReadAt: readAt, + }) - assert.Equal(t, map[string]any{ - "is_read": true, - "last_read_at": readAt, - }, updates) - assert.NotContains(t, updates, "is_archived") + assert.Equal(t, map[string]any{ + "is_read": true, + "last_read_at": readAt, + }, updates) + assert.NotContains(t, updates, "is_archived") } func TestMessageThreadStatusUpdatesArchiveOnly(t *testing.T) { - isArchived := true + isArchived := true - updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ - IsArchived: &isArchived, - }) + updates := messageThreadStatusUpdates(MessageThreadStatusUpdate{ + IsArchived: &isArchived, + }) - assert.Equal(t, map[string]any{"is_archived": true}, updates) - assert.NotContains(t, updates, "is_read") - assert.NotContains(t, updates, "last_read_at") + assert.Equal(t, map[string]any{"is_archived": true}, updates) + assert.NotContains(t, updates, "is_read") + assert.NotContains(t, updates, "last_read_at") } ``` @@ -373,28 +373,28 @@ In `message_thread_repository.go`, add: ```go type MessageThreadActivityUpdate struct { - MessageThreadID uuid.UUID - UserID entities.UserID - Timestamp time.Time - MessageID uuid.UUID - Content string - Status entities.MessageStatus - MarksUnread bool - EventTimestamp time.Time + MessageThreadID uuid.UUID + UserID entities.UserID + Timestamp time.Time + MessageID uuid.UUID + Content string + Status entities.MessageStatus + MarksUnread bool + EventTimestamp time.Time } type MessageThreadStatusUpdate struct { - IsArchived *bool - IsRead *bool - ReadAt time.Time + IsArchived *bool + IsRead *bool + ReadAt time.Time } type MessageThreadDeletedUpdate struct { - MessageThreadID uuid.UUID - UserID entities.UserID - LastMessageID *uuid.UUID - LastMessageContent *string - LastMessageStatus entities.MessageStatus + MessageThreadID uuid.UUID + UserID entities.UserID + LastMessageID *uuid.UUID + LastMessageContent *string + LastMessageStatus entities.MessageStatus } ``` @@ -412,26 +412,26 @@ Add these helpers to `gorm_message_thread_repository.go`: ```go func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string]any { - return map[string]any{ - "order_timestamp": params.Timestamp, - "last_message_id": params.MessageID, - "last_message_content": params.Content, - "status": params.Status, - } + return map[string]any{ + "order_timestamp": params.Timestamp, + "last_message_id": params.MessageID, + "last_message_content": params.Content, + "status": params.Status, + } } func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any { - updates := make(map[string]any) - if params.IsArchived != nil { - updates["is_archived"] = *params.IsArchived - } - if params.IsRead != nil { - updates["is_read"] = *params.IsRead - if *params.IsRead { - updates["last_read_at"] = params.ReadAt - } - } - return updates + updates := make(map[string]any) + if params.IsArchived != nil { + updates["is_archived"] = *params.IsArchived + } + if params.IsRead != nil { + updates["is_read"] = *params.IsRead + if *params.IsRead { + updates["last_read_at"] = params.ReadAt + } + } + return updates } ``` @@ -439,87 +439,87 @@ Replace the full-row `Update` implementation with: ```go func (repository *gormMessageThreadRepository) UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error { - ctx, span := repository.tracer.Start(ctx) - defer span.End() - - err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - query := tx.Model(&entities.MessageThread{}). - Where("user_id = ?", params.UserID). - Where("id = ?", params.MessageThreadID) - - result := query.Updates(messageThreadActivityUpdates(params)) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return stacktrace.PropagateWithCode( - gorm.ErrRecordNotFound, - ErrCodeNotFound, - fmt.Sprintf("thread with id [%s] not found", params.MessageThreadID), - ) - } - - if !params.MarksUnread { - return nil - } - - return tx.Model(&entities.MessageThread{}). - Where("user_id = ?", params.UserID). - Where("id = ?", params.MessageThreadID). - Where("last_read_at < ?", params.EventTimestamp). - Update("is_read", false). - Error - }) - if err != nil { - msg := fmt.Sprintf("cannot update message activity for thread [%s]", params.MessageThreadID) - return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) - } - return nil + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID) + + result := query.Updates(messageThreadActivityUpdates(params)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return stacktrace.PropagateWithCode( + gorm.ErrRecordNotFound, + ErrCodeNotFound, + fmt.Sprintf("thread with id [%s] not found", params.MessageThreadID), + ) + } + + if !params.MarksUnread { + return nil + } + + return tx.Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Where("last_read_at < ?", params.EventTimestamp). + Update("is_read", false). + Error + }) + if err != nil { + msg := fmt.Sprintf("cannot update message activity for thread [%s]", params.MessageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } + return nil } func (repository *gormMessageThreadRepository) UpdateStatus( - ctx context.Context, - userID entities.UserID, - messageThreadID uuid.UUID, - params MessageThreadStatusUpdate, + ctx context.Context, + userID entities.UserID, + messageThreadID uuid.UUID, + params MessageThreadStatusUpdate, ) error { - ctx, span := repository.tracer.Start(ctx) - defer span.End() - - result := repository.db.WithContext(ctx). - Model(&entities.MessageThread{}). - Where("user_id = ?", userID). - Where("id = ?", messageThreadID). - Updates(messageThreadStatusUpdates(params)) - if result.Error != nil { - msg := fmt.Sprintf("cannot update status for thread [%s]", messageThreadID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) - } - if result.RowsAffected == 0 { - msg := fmt.Sprintf("thread with id [%s] not found", messageThreadID) - return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, msg)) - } - return nil + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", userID). + Where("id = ?", messageThreadID). + Updates(messageThreadStatusUpdates(params)) + if result.Error != nil { + msg := fmt.Sprintf("cannot update status for thread [%s]", messageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) + } + if result.RowsAffected == 0 { + msg := fmt.Sprintf("thread with id [%s] not found", messageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, msg)) + } + return nil } func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx context.Context, params MessageThreadDeletedUpdate) error { - ctx, span := repository.tracer.Start(ctx) - defer span.End() - - result := repository.db.WithContext(ctx). - Model(&entities.MessageThread{}). - Where("user_id = ?", params.UserID). - Where("id = ?", params.MessageThreadID). - Updates(map[string]any{ - "last_message_id": params.LastMessageID, - "last_message_content": params.LastMessageContent, - "status": params.LastMessageStatus, - }) - if result.Error != nil { - msg := fmt.Sprintf("cannot update deleted-message metadata for thread [%s]", params.MessageThreadID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) - } - return nil + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Updates(map[string]any{ + "last_message_id": params.LastMessageID, + "last_message_content": params.LastMessageContent, + "status": params.LastMessageStatus, + }) + if result.Error != nil { + msg := fmt.Sprintf("cannot update deleted-message metadata for thread [%s]", params.MessageThreadID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, msg)) + } + return nil } ``` @@ -574,78 +574,78 @@ Create `api/pkg/services/message_thread_service_test.go`. Implement a repository package services import ( - "context" - "testing" - "time" - - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/NdoleStudio/httpsms/pkg/repositories" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/google/uuid" - "github.com/palantir/stacktrace" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/palantir/stacktrace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" ) 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) - store func(context.Context, *entities.MessageThread) error - updateActivity func(context.Context, repositories.MessageThreadActivityUpdate) error - updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error + loadByOwnerContact func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) + load func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) + store func(context.Context, *entities.MessageThread) error + updateActivity func(context.Context, repositories.MessageThreadActivityUpdate) error + updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error } func (stub *messageThreadRepositoryStub) Store(ctx context.Context, thread *entities.MessageThread) error { - if stub.store != nil { - return stub.store(ctx, thread) - } - return nil + if stub.store != nil { + return stub.store(ctx, thread) + } + return nil } func (stub *messageThreadRepositoryStub) UpdateActivity(ctx context.Context, params repositories.MessageThreadActivityUpdate) error { - if stub.updateActivity != nil { - return stub.updateActivity(ctx, params) - } - return nil + if stub.updateActivity != nil { + return stub.updateActivity(ctx, params) + } + return nil } func (stub *messageThreadRepositoryStub) UpdateStatus(ctx context.Context, userID entities.UserID, threadID uuid.UUID, params repositories.MessageThreadStatusUpdate) error { - if stub.updateStatus != nil { - return stub.updateStatus(ctx, userID, threadID, params) - } - return nil + if stub.updateStatus != nil { + return stub.updateStatus(ctx, userID, threadID, params) + } + return nil } func (stub *messageThreadRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { - return nil + return nil } func (stub *messageThreadRepositoryStub) LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) { - return stub.loadByOwnerContact(ctx, userID, owner, contact) + return stub.loadByOwnerContact(ctx, userID, owner, contact) } func (stub *messageThreadRepositoryStub) Load(ctx context.Context, userID entities.UserID, id uuid.UUID) (*entities.MessageThread, error) { - return stub.load(ctx, userID, id) + return stub.load(ctx, userID, id) } func (stub *messageThreadRepositoryStub) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { - threads := []entities.MessageThread{} - return &threads, nil + threads := []entities.MessageThread{} + return &threads, nil } func (stub *messageThreadRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { - return nil + return nil } func (stub *messageThreadRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { - return nil + return nil } func newMessageThreadServiceForTest(repository repositories.MessageThreadRepository) *MessageThreadService { - logger := &noopLogger{} - tracer := telemetry.NewOtelLogger("test", logger) - return NewMessageThreadService(logger, tracer, repository, nil) + logger := &noopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewMessageThreadService(logger, tracer, repository, nil) } ``` @@ -653,151 +653,151 @@ Add these tests below the stub: ```go func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { - threadID := uuid.New() - eventTimestamp := time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC) - var captured repositories.MessageThreadActivityUpdate - repository := &messageThreadRepositoryStub{ - loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { - return &entities.MessageThread{ID: threadID}, nil - }, - updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { - captured = params - return nil - }, - } - - service := newMessageThreadServiceForTest(repository) - err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ - UserID: entities.UserID("user-id"), - Owner: "+18005550199", - Contact: "+18005550100", - MessageID: uuid.New(), - Content: "hello", - Status: entities.MessageStatusReceived, - Timestamp: eventTimestamp, - MarksUnread: true, - EventTimestamp: eventTimestamp, - }) - - require.NoError(t, err) - assert.True(t, captured.MarksUnread) - assert.Equal(t, eventTimestamp, captured.EventTimestamp) + threadID := uuid.New() + eventTimestamp := time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC) + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: eventTimestamp, + MarksUnread: true, + EventTimestamp: eventTimestamp, + }) + + require.NoError(t, err) + assert.True(t, captured.MarksUnread) + assert.Equal(t, eventTimestamp, captured.EventTimestamp) } func TestUpdateThreadPreservesReadStateForOutboundActivity(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(), IsRead: false}, nil - }, - updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { - captured = params - return nil - }, - } - - service := newMessageThreadServiceForTest(repository) - err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ - UserID: entities.UserID("user-id"), - Owner: "+18005550199", - Contact: "+18005550100", - MessageID: uuid.New(), - Content: "outbound", - Status: entities.MessageStatusSent, - Timestamp: time.Now().UTC(), - }) - - require.NoError(t, err) - assert.False(t, captured.MarksUnread) + var captured repositories.MessageThreadActivityUpdate + repository := &messageThreadRepositoryStub{ + loadByOwnerContact: func(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: uuid.New(), IsRead: false}, nil + }, + updateActivity: func(_ context.Context, params repositories.MessageThreadActivityUpdate) error { + captured = params + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "outbound", + Status: entities.MessageStatusSent, + Timestamp: time.Now().UTC(), + }) + + require.NoError(t, err) + assert.False(t, captured.MarksUnread) } func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { - tests := []struct { - name string - marksUnread bool - wantRead bool - }{ - {name: "inbound", marksUnread: true, wantRead: false}, - {name: "outbound", marksUnread: false, wantRead: true}, - } - - 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.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") - }, - store: func(_ context.Context, thread *entities.MessageThread) error { - stored = thread - return nil - }, - } - - service := newMessageThreadServiceForTest(repository) - err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ - UserID: entities.UserID("user-id"), - Owner: "+18005550199", - Contact: "+18005550100", - MessageID: uuid.New(), - Content: "hello", - Status: entities.MessageStatusReceived, - Timestamp: time.Now().UTC(), - MarksUnread: test.marksUnread, - }) - - require.NoError(t, err) - require.NotNil(t, stored) - assert.Equal(t, test.wantRead, stored.IsRead) - assert.False(t, stored.LastReadAt.IsZero()) - }) - } + tests := []struct { + name string + marksUnread bool + wantRead bool + }{ + {name: "inbound", marksUnread: true, wantRead: false}, + {name: "outbound", marksUnread: false, wantRead: true}, + } + + 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.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + store: func(_ context.Context, thread *entities.MessageThread) error { + stored = thread + return nil + }, + } + + service := newMessageThreadServiceForTest(repository) + err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: time.Now().UTC(), + MarksUnread: test.marksUnread, + }) + + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, test.wantRead, stored.IsRead) + assert.False(t, stored.LastReadAt.IsZero()) + }) + } } func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { - threadID := uuid.New() - isRead := true - var captured repositories.MessageThreadStatusUpdate - repository := &messageThreadRepositoryStub{ - updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) error { - captured = params - return nil - }, - load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { - return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: true}, nil - }, - } - - service := newMessageThreadServiceForTest(repository) - thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ - UserID: entities.UserID("user-id"), - MessageThreadID: threadID, - IsRead: &isRead, - }) - - require.NoError(t, err) - assert.Nil(t, captured.IsArchived) - assert.Same(t, &isRead, captured.IsRead) - assert.False(t, captured.ReadAt.IsZero()) - assert.True(t, thread.IsArchived) + threadID := uuid.New() + isRead := true + var captured repositories.MessageThreadStatusUpdate + repository := &messageThreadRepositoryStub{ + updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) error { + captured = params + return nil + }, + load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: true}, nil + }, + } + + service := newMessageThreadServiceForTest(repository) + thread, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: threadID, + IsRead: &isRead, + }) + + require.NoError(t, err) + assert.Nil(t, captured.IsArchived) + assert.Same(t, &isRead, captured.IsRead) + assert.False(t, captured.ReadAt.IsZero()) + assert.True(t, thread.IsArchived) } func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { - repository := &messageThreadRepositoryStub{ - updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { - return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") - }, - } + repository := &messageThreadRepositoryStub{ + updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { + return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + }, + } - service := newMessageThreadServiceForTest(repository) - isRead := true - _, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ - UserID: entities.UserID("user-id"), - MessageThreadID: uuid.New(), - IsRead: &isRead, - }) + service := newMessageThreadServiceForTest(repository) + isRead := true + _, err := service.UpdateStatus(context.Background(), MessageThreadStatusParams{ + UserID: entities.UserID("user-id"), + MessageThreadID: uuid.New(), + IsRead: &isRead, + }) - assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) + assert.Equal(t, repositories.ErrCodeNotFound, stacktrace.GetCode(err)) } ``` @@ -824,17 +824,17 @@ Replace the existing full-row update in `UpdateThread` with: ```go if err = service.repository.UpdateActivity(ctx, repositories.MessageThreadActivityUpdate{ - MessageThreadID: thread.ID, - UserID: params.UserID, - Timestamp: params.Timestamp, - MessageID: params.MessageID, - Content: params.Content, - Status: params.Status, - MarksUnread: params.MarksUnread, - EventTimestamp: params.EventTimestamp, + MessageThreadID: thread.ID, + UserID: params.UserID, + Timestamp: params.Timestamp, + MessageID: params.MessageID, + Content: params.Content, + Status: params.Status, + MarksUnread: params.MarksUnread, + EventTimestamp: params.EventTimestamp, }); err != nil { - msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) - return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) + return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } ``` @@ -851,25 +851,25 @@ Replace `UpdateStatus` with a partial repository update followed by a reload: ```go func (service *MessageThreadService) UpdateStatus(ctx context.Context, params MessageThreadStatusParams) (*entities.MessageThread, error) { - ctx, span := service.tracer.Start(ctx) - defer span.End() + ctx, span := service.tracer.Start(ctx) + defer span.End() - update := repositories.MessageThreadStatusUpdate{ - IsArchived: params.IsArchived, - IsRead: params.IsRead, - ReadAt: time.Now().UTC(), - } - if err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { - msg := fmt.Sprintf("cannot update message thread with id [%s]", params.MessageThreadID) - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) - } + update := repositories.MessageThreadStatusUpdate{ + IsArchived: params.IsArchived, + IsRead: params.IsRead, + ReadAt: time.Now().UTC(), + } + if err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { + msg := fmt.Sprintf("cannot update message thread with id [%s]", params.MessageThreadID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } - thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) - if err != nil { - msg := fmt.Sprintf("cannot reload message thread with id [%s]", params.MessageThreadID) - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) - } - return thread, nil + thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) + if err != nil { + msg := fmt.Sprintf("cannot reload message thread with id [%s]", params.MessageThreadID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) + } + return thread, nil } ``` @@ -877,14 +877,14 @@ In `UpdateAfterDeletedMessage`, replace the full-row update with: ```go if err = service.repository.UpdateAfterDeletedMessage(ctx, repositories.MessageThreadDeletedUpdate{ - MessageThreadID: thread.ID, - UserID: thread.UserID, - LastMessageID: payload.PreviousMessageID, - LastMessageContent: payload.PreviousMessageContent, - LastMessageStatus: *payload.PreviousMessageStatus, + MessageThreadID: thread.ID, + UserID: thread.UserID, + LastMessageID: payload.PreviousMessageID, + LastMessageContent: payload.PreviousMessageContent, + LastMessageStatus: *payload.PreviousMessageStatus, }); err != nil { - msg := fmt.Sprintf("cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID) - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + msg := fmt.Sprintf("cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ``` @@ -907,31 +907,31 @@ Add: ```go func (listener *MessageThreadListener) OnMessageCallMissed(ctx context.Context, event cloudevents.Event) error { - ctx, span := listener.tracer.Start(ctx) - defer span.End() - - var payload events.MessageCallMissedPayload - if err := event.DataAs(&payload); err != nil { - msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - - params := services.MessageThreadUpdateParams{ - Owner: payload.Owner, - Contact: payload.Contact, - UserID: payload.UserID, - Status: entities.MessageStatusReceived, - Timestamp: payload.Timestamp, - Content: "Missed phone call", - MessageID: payload.MessageID, - MarksUnread: true, - EventTimestamp: event.Time(), - } - if err := listener.service.UpdateThread(ctx, params); err != nil { - msg := fmt.Sprintf("cannot update thread for missed call [%s] on event [%s]", payload.MessageID, event.ID()) - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - return nil + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + params := services.MessageThreadUpdateParams{ + Owner: payload.Owner, + Contact: payload.Contact, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Timestamp: payload.Timestamp, + Content: "Missed phone call", + MessageID: payload.MessageID, + MarksUnread: true, + EventTimestamp: event.Time(), + } + if err := listener.service.UpdateThread(ctx, params); err != nil { + msg := fmt.Sprintf("cannot update thread for missed call [%s] on event [%s]", payload.MessageID, event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil } ``` @@ -945,20 +945,20 @@ Add: ```go func (listener *WebsocketListener) onMessageCallMissed(ctx context.Context, event cloudevents.Event) error { - ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) - defer span.End() + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() - var payload events.MessageCallMissedPayload - if err := event.DataAs(&payload); err != nil { - msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } + var payload events.MessageCallMissedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } - if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { - msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID) - return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - return nil + if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { + msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil } ``` @@ -970,13 +970,13 @@ Create `api/pkg/listeners/read_receipts_test_helpers_test.go`: package listeners import ( - "context" + "context" - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/NdoleStudio/httpsms/pkg/repositories" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/google/uuid" - "go.opentelemetry.io/otel/trace" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" ) type noopListenerLogger struct{} @@ -993,45 +993,45 @@ func (logger *noopListenerLogger) Fatal(error) { func (logger *noopListenerLogger) Printf(string, ...interface{}) {} type listenerMessageThreadRepository struct { - activity repositories.MessageThreadActivityUpdate + activity repositories.MessageThreadActivityUpdate } func (repository *listenerMessageThreadRepository) Store(context.Context, *entities.MessageThread) error { - return nil + return nil } func (repository *listenerMessageThreadRepository) UpdateActivity(_ context.Context, params repositories.MessageThreadActivityUpdate) error { - repository.activity = params - return nil + repository.activity = params + return nil } func (repository *listenerMessageThreadRepository) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { - return nil + return nil } func (repository *listenerMessageThreadRepository) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { - return nil + return nil } func (repository *listenerMessageThreadRepository) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { - return &entities.MessageThread{ID: uuid.New()}, nil + return &entities.MessageThread{ID: uuid.New()}, nil } func (repository *listenerMessageThreadRepository) Load(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { - return &entities.MessageThread{}, nil + return &entities.MessageThread{}, nil } func (repository *listenerMessageThreadRepository) Index(context.Context, entities.UserID, string, bool, repositories.IndexParams) (*[]entities.MessageThread, error) { - threads := []entities.MessageThread{} - return &threads, nil + threads := []entities.MessageThread{} + return &threads, nil } func (repository *listenerMessageThreadRepository) Delete(context.Context, entities.UserID, uuid.UUID) error { - return nil + return nil } func (repository *listenerMessageThreadRepository) DeleteAllForUser(context.Context, entities.UserID) error { - return nil + return nil } ``` @@ -1041,46 +1041,46 @@ Create `api/pkg/listeners/message_thread_listener_test.go`: package listeners import ( - "context" - "testing" - "time" - - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/NdoleStudio/httpsms/pkg/events" - "github.com/NdoleStudio/httpsms/pkg/services" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "context" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMessageThreadListenerMarksMissedCallUnread(t *testing.T) { - repository := &listenerMessageThreadRepository{} - logger := &noopListenerLogger{} - tracer := telemetry.NewOtelLogger("test", logger) - service := services.NewMessageThreadService(logger, tracer, repository, nil) - _, routes := NewMessageThreadListener(logger, tracer, service) - - event := cloudevents.NewEvent() - event.SetID(uuid.NewString()) - event.SetSource("/v1/messages/call-missed") - event.SetType(events.MessageCallMissed) - event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) - require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessageCallMissedPayload{ - MessageID: uuid.New(), - UserID: entities.UserID("user-id"), - Owner: "+18005550199", - Contact: "+18005550100", - Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), - })) - - err := routes[events.MessageCallMissed](context.Background(), event) - - require.NoError(t, err) - assert.True(t, repository.activity.MarksUnread) - assert.Equal(t, "Missed phone call", repository.activity.Content) - assert.Equal(t, event.Time(), repository.activity.EventTimestamp) + repository := &listenerMessageThreadRepository{} + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + service := services.NewMessageThreadService(logger, tracer, repository, nil) + _, routes := NewMessageThreadListener(logger, tracer, service) + + event := cloudevents.NewEvent() + event.SetID(uuid.NewString()) + event.SetSource("/v1/messages/call-missed") + event.SetType(events.MessageCallMissed) + event.SetTime(time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)) + require.NoError(t, event.SetData(cloudevents.ApplicationJSON, events.MessageCallMissedPayload{ + MessageID: uuid.New(), + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + Timestamp: time.Date(2026, 7, 18, 6, 59, 0, 0, time.UTC), + })) + + err := routes[events.MessageCallMissed](context.Background(), event) + + require.NoError(t, err) + assert.True(t, repository.activity.MarksUnread) + assert.Equal(t, "Missed phone call", repository.activity.Content) + assert.Equal(t, event.Time(), repository.activity.EventTimestamp) } ``` @@ -1090,20 +1090,20 @@ Create `api/pkg/listeners/websocket_listener_test.go`: package listeners import ( - "testing" + "testing" - "github.com/NdoleStudio/httpsms/pkg/events" - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/pusher/pusher-http-go/v5" - "github.com/stretchr/testify/assert" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/pusher/pusher-http-go/v5" + "github.com/stretchr/testify/assert" ) func TestWebsocketListenerRegistersMissedCalls(t *testing.T) { - logger := &noopListenerLogger{} - tracer := telemetry.NewOtelLogger("test", logger) - _, routes := NewWebsocketListener(logger, tracer, &pusher.Client{}) + logger := &noopListenerLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + _, routes := NewWebsocketListener(logger, tracer, &pusher.Client{}) - assert.Contains(t, routes, events.MessageCallMissed) + assert.Contains(t, routes, events.MessageCallMissed) } ``` @@ -1149,8 +1149,8 @@ Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 Change the update annotation: ```go -// @Success 200 {object} responses.MessageThreadResponse -// @Failure 404 {object} responses.NotFound +// @Success 200 {object} responses.MessageThreadResponse +// @Failure 404 {object} responses.NotFound ``` Change the error block: @@ -1158,12 +1158,12 @@ Change the error block: ```go thread, err := h.service.UpdateStatus(ctx, request.ToUpdateParams(h.userIDFomContext(c))) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { - return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", request.MessageThreadID)) + return h.responseNotFound(c, fmt.Sprintf("cannot find message thread with ID [%s]", request.MessageThreadID)) } if err != nil { - msg := fmt.Sprintf("cannot update message thread with params [%+#v]", request) - ctxLogger.Error(stacktrace.Propagate(err, msg)) - return h.responseInternalServerError(c) + msg := fmt.Sprintf("cannot update message thread with params [%+#v]", request) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) } ``` @@ -1442,194 +1442,194 @@ Create `tests/read_receipts_test.go`: package tests import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "testing" - "time" - - httpsms "github.com/NdoleStudio/httpsms-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type integrationMessageThread struct { - ID string `json:"id"` - Contact string `json:"contact"` - IsRead bool `json:"is_read"` - LastMessageContent *string `json:"last_message_content"` + ID string `json:"id"` + Contact string `json:"contact"` + IsRead bool `json:"is_read"` + LastMessageContent *string `json:"last_message_content"` } func requestJSON( - ctx context.Context, - t *testing.T, - method string, - path string, - apiKey string, - payload any, - expectedStatus int, - output any, + ctx context.Context, + t *testing.T, + method string, + path string, + apiKey string, + payload any, + expectedStatus int, + output any, ) { - t.Helper() + t.Helper() - var body io.Reader - if payload != nil { - encoded, err := json.Marshal(payload) - require.NoError(t, err) - body = bytes.NewReader(encoded) - } + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } - request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, body) - require.NoError(t, err) - request.Header.Set("x-api-key", apiKey) - request.Header.Set("Content-Type", "application/json") + request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, body) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + request.Header.Set("Content-Type", "application/json") - response, err := http.DefaultClient.Do(request) - require.NoError(t, err) - defer response.Body.Close() + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() - responseBody, err := io.ReadAll(response.Body) - require.NoError(t, err) - require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody)) - if output != nil { - require.NoError(t, json.Unmarshal(responseBody, output)) - } + if output != nil { + require.NoError(t, json.Unmarshal(responseBody, output)) + } } func fetchMessageThreads(ctx context.Context, t *testing.T, owner string) []integrationMessageThread { - t.Helper() + t.Helper() - var response struct { - Data []integrationMessageThread `json:"data"` - } - path := fmt.Sprintf( - "/v1/message-threads?owner=%s&skip=0&limit=20&is_archived=false", - url.QueryEscape(owner), - ) - requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) - return response.Data + var response struct { + Data []integrationMessageThread `json:"data"` + } + path := fmt.Sprintf( + "/v1/message-threads?owner=%s&skip=0&limit=20&is_archived=false", + url.QueryEscape(owner), + ) + requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response) + return response.Data } func waitForMessageThread( - ctx context.Context, - t *testing.T, - owner string, - contact string, - timeout time.Duration, - matches func(integrationMessageThread) bool, + ctx context.Context, + t *testing.T, + owner string, + contact string, + timeout time.Duration, + matches func(integrationMessageThread) bool, ) integrationMessageThread { - t.Helper() + t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - for _, thread := range fetchMessageThreads(ctx, t, owner) { - if thread.Contact == contact && matches(thread) { - return thread - } - } - time.Sleep(500 * time.Millisecond) - } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, thread := range fetchMessageThreads(ctx, t, owner) { + if thread.Contact == contact && matches(thread) { + return thread + } + } + time.Sleep(500 * time.Millisecond) + } - t.Fatalf("thread %s -> %s did not reach the expected state within %v", owner, contact, timeout) - return integrationMessageThread{} + t.Fatalf("thread %s -> %s did not reach the expected state within %v", owner, contact, timeout) + return integrationMessageThread{} } func markMessageThreadRead(ctx context.Context, t *testing.T, threadID string) integrationMessageThread { - t.Helper() - - var response struct { - Data integrationMessageThread `json:"data"` - } - requestJSON( - ctx, - t, - http.MethodPut, - "/v1/message-threads/"+threadID, - userAPIKey, - map[string]any{"is_read": true}, - http.StatusOK, - &response, - ) - return response.Data + t.Helper() + + var response struct { + Data integrationMessageThread `json:"data"` + } + requestJSON( + ctx, + t, + http.MethodPut, + "/v1/message-threads/"+threadID, + userAPIKey, + map[string]any{"is_read": true}, + http.StatusOK, + &response, + ) + return response.Data } func TestMessageThreadReadReceipts(t *testing.T) { - ctx := context.Background() - phone := setupPhone(ctx, t, 60) - contact := randomPhoneNumber() - - requestJSON( - ctx, - t, - http.MethodPost, - "/v1/messages/receive", - phone.PhoneAPIKey, - map[string]any{ - "from": contact, - "to": phone.PhoneNumber, - "content": "Unread inbound message", - "encrypted": false, - "sim": "SIM1", - "timestamp": time.Now().UTC().Format(time.RFC3339), - }, - http.StatusOK, - nil, - ) - - thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { - return !thread.IsRead - }) - assert.False(t, thread.IsRead) - - updated := markMessageThreadRead(ctx, t, thread.ID) - assert.True(t, updated.IsRead) - waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool { - return thread.IsRead - }) - - requestJSON( - ctx, - t, - http.MethodPost, - "/v1/messages/calls/missed", - phone.PhoneAPIKey, - map[string]any{ - "from": contact, - "to": phone.PhoneNumber, - "sim": "SIM1", - "timestamp": time.Now().UTC().Format(time.RFC3339), - }, - http.StatusOK, - nil, - ) - - thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { - return !thread.IsRead && - thread.LastMessageContent != nil && - *thread.LastMessageContent == "Missed phone call" - }) - assert.False(t, thread.IsRead) - - outboundContent := "Outbound activity preserves unread" - client := newAPIClient() - _, response, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ - From: phone.PhoneNumber, - To: contact, - Content: outboundContent, - }) - require.NoError(t, err) - require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) - - thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { - return thread.LastMessageContent != nil && - *thread.LastMessageContent == outboundContent - }) - assert.False(t, thread.IsRead, "outbound activity must not clear unread state") + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + contact := randomPhoneNumber() + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/receive", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "content": "Unread inbound message", + "encrypted": false, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + http.StatusOK, + nil, + ) + + thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return !thread.IsRead + }) + assert.False(t, thread.IsRead) + + updated := markMessageThreadRead(ctx, t, thread.ID) + assert.True(t, updated.IsRead) + waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool { + return thread.IsRead + }) + + requestJSON( + ctx, + t, + http.MethodPost, + "/v1/messages/calls/missed", + phone.PhoneAPIKey, + map[string]any{ + "from": contact, + "to": phone.PhoneNumber, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + http.StatusOK, + nil, + ) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return !thread.IsRead && + thread.LastMessageContent != nil && + *thread.LastMessageContent == "Missed phone call" + }) + assert.False(t, thread.IsRead) + + outboundContent := "Outbound activity preserves unread" + client := newAPIClient() + _, response, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: outboundContent, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + + thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool { + return thread.LastMessageContent != nil && + *thread.LastMessageContent == outboundContent + }) + assert.False(t, thread.IsRead, "outbound activity must not clear unread state") } ``` From ba466a7f291b72d448e509367f6f6c211f9eff12 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 13:17:22 +0300 Subject: [PATCH 20/22] fix(api): return updated thread atomically Use PostgreSQL RETURNING so concurrent activity cannot make status update responses stale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- .../handlers/message_thread_handler_test.go | 4 +-- .../read_receipts_test_helpers_test.go | 4 +-- .../gorm_message_thread_repository.go | 28 +++++++++++-------- .../gorm_message_thread_repository_test.go | 18 +++++++++++- .../repositories/message_thread_repository.go | 2 +- api/pkg/services/message_thread_service.go | 20 ++----------- .../services/message_thread_service_test.go | 24 +++++----------- tests/read_receipts_test.go | 3 ++ 8 files changed, 52 insertions(+), 51 deletions(-) diff --git a/api/pkg/handlers/message_thread_handler_test.go b/api/pkg/handlers/message_thread_handler_test.go index cdb4444f2..ab2d75962 100644 --- a/api/pkg/handlers/message_thread_handler_test.go +++ b/api/pkg/handlers/message_thread_handler_test.go @@ -33,8 +33,8 @@ func (stub *messageThreadHandlerRepositoryStub) UpdateActivity(context.Context, return nil } -func (stub *messageThreadHandlerRepositoryStub) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { - return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") +func (stub *messageThreadHandlerRepositoryStub) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") } func (stub *messageThreadHandlerRepositoryStub) LoadByOwnerContact(context.Context, entities.UserID, string, string) (*entities.MessageThread, error) { diff --git a/api/pkg/listeners/read_receipts_test_helpers_test.go b/api/pkg/listeners/read_receipts_test_helpers_test.go index db4a14227..60cd27e65 100644 --- a/api/pkg/listeners/read_receipts_test_helpers_test.go +++ b/api/pkg/listeners/read_receipts_test_helpers_test.go @@ -36,8 +36,8 @@ func (repository *listenerMessageThreadRepository) UpdateActivity(_ context.Cont return nil } -func (repository *listenerMessageThreadRepository) UpdateStatus(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { - return nil +func (repository *listenerMessageThreadRepository) UpdateStatus(_ context.Context, _ entities.UserID, threadID uuid.UUID, _ repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + return &entities.MessageThread{ID: threadID}, nil } func (repository *listenerMessageThreadRepository) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index dcf16a5f1..3a7996b5a 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -40,7 +40,7 @@ func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string "order_timestamp": params.Timestamp, "last_message_id": params.MessageID, "last_message_content": params.Content, - "status": string(params.Status), + "status": params.Status, } if params.Unarchive { updates["is_archived"] = false @@ -48,6 +48,14 @@ func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string return updates } +func messageThreadDeletedUpdates(params MessageThreadDeletedUpdate) map[string]any { + return map[string]any{ + "last_message_id": params.LastMessageID, + "last_message_content": params.LastMessageContent, + "status": params.LastMessageStatus, + } +} + func messageThreadStatusUpdates(params MessageThreadStatusUpdate) map[string]any { updates := make(map[string]any) if params.IsArchived != nil { @@ -95,11 +103,7 @@ func (repository *gormMessageThreadRepository) UpdateAfterDeletedMessage(ctx con Model(&entities.MessageThread{}). Where("user_id = ?", params.UserID). Where("id = ?", params.MessageThreadID). - Updates(map[string]any{ - "last_message_id": params.LastMessageID, - "last_message_content": params.LastMessageContent, - "status": string(params.LastMessageStatus), - }) + Updates(messageThreadDeletedUpdates(params)) if result.Error != nil { return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, "cannot update deleted-message metadata for thread [%s]", params.MessageThreadID)) } @@ -183,23 +187,25 @@ func (repository *gormMessageThreadRepository) UpdateStatus( userID entities.UserID, messageThreadID uuid.UUID, params MessageThreadStatusUpdate, -) error { +) (*entities.MessageThread, error) { ctx, span := repository.tracer.Start(ctx) defer span.End() + thread := new(entities.MessageThread) result := repository.db.WithContext(ctx). - Model(&entities.MessageThread{}). + Model(thread). + Clauses(clause.Returning{}). Where("user_id = ?", userID). Where("id = ?", messageThreadID). Updates(messageThreadStatusUpdates(params)) if result.Error != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, "cannot update status for thread [%s]", messageThreadID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, "cannot update status for thread [%s]", messageThreadID)) } if result.RowsAffected == 0 { - return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found", messageThreadID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found", messageThreadID)) } - return nil + return thread, nil } // LoadByOwnerContact a thread between 2 users diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go index 8143f03cf..f634c2ce4 100644 --- a/api/pkg/repositories/gorm_message_thread_repository_test.go +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -116,13 +116,29 @@ func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { "order_timestamp": time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC), "last_message_id": messageID, "last_message_content": "hello", - "status": entities.MessageStatusReceived, + "status": entities.MessageStatus(entities.MessageStatusReceived), }, updates) assert.NotContains(t, updates, "is_read") assert.NotContains(t, updates, "is_archived") assert.NotContains(t, updates, "last_read_at") } +func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) { + messageID := uuid.New() + content := "previous message" + updates := messageThreadDeletedUpdates(MessageThreadDeletedUpdate{ + LastMessageID: &messageID, + LastMessageContent: &content, + LastMessageStatus: entities.MessageStatusDelivered, + }) + + assert.Equal(t, map[string]any{ + "last_message_id": &messageID, + "last_message_content": &content, + "status": entities.MessageStatus(entities.MessageStatusDelivered), + }, updates) +} + func TestMessageThreadStatusUpdatesReadOnly(t *testing.T) { isRead := true readAt := time.Date(2026, 7, 18, 7, 1, 0, 0, time.UTC) diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go index 852596ec6..3efe3fb1e 100644 --- a/api/pkg/repositories/message_thread_repository.go +++ b/api/pkg/repositories/message_thread_repository.go @@ -44,7 +44,7 @@ type MessageThreadRepository interface { UpdateActivity(ctx context.Context, params MessageThreadActivityUpdate) error // UpdateStatus persists archive/read status fields for a thread - UpdateStatus(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID, params MessageThreadStatusUpdate) error + UpdateStatus(ctx context.Context, userID entities.UserID, messageThreadID uuid.UUID, params MessageThreadStatusUpdate) (*entities.MessageThread, error) // LoadByOwnerContact fetches a thread between owner and contact LoadByOwnerContact(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.MessageThread, error) diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index d7345043c..bb57538bd 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -128,7 +128,7 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID)) } - ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with last message [%s] and status [%s]", thread.ID, thread.LastMessageID, thread.Status)) + ctxLogger.Info(fmt.Sprintf("thread with id [%s] updated with last message [%s] and status [%s]", thread.ID, params.MessageID, params.Status)) return nil } @@ -145,30 +145,16 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me ctx, span := service.tracer.Start(ctx) defer span.End() - thread, err := service.repository.Load(ctx, params.UserID, params.MessageThreadID) - if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot find thread with id [%s]", params.MessageThreadID)) - } - update := repositories.MessageThreadStatusUpdate{ IsArchived: params.IsArchived, IsRead: params.IsRead, ReadAt: time.Now().UTC(), } - if err = service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update); err != nil { + thread, err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update) + if err != nil { return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot update message thread with id [%s]", params.MessageThreadID)) } - if params.IsArchived != nil { - thread.IsArchived = *params.IsArchived - } - if params.IsRead != nil { - thread.IsRead = *params.IsRead - if *params.IsRead { - thread.LastReadAt = update.ReadAt - } - } - return thread, nil } diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go index 556e9792a..d31b98ddf 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -20,7 +20,7 @@ type messageThreadRepositoryStub struct { load func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) store func(context.Context, *entities.MessageThread) error updateActivity func(context.Context, repositories.MessageThreadActivityUpdate) error - updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error + updateStatus func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) } func (stub *messageThreadRepositoryStub) Store(ctx context.Context, thread *entities.MessageThread) error { @@ -37,11 +37,11 @@ func (stub *messageThreadRepositoryStub) UpdateActivity(ctx context.Context, par return nil } -func (stub *messageThreadRepositoryStub) UpdateStatus(ctx context.Context, userID entities.UserID, threadID uuid.UUID, params repositories.MessageThreadStatusUpdate) error { +func (stub *messageThreadRepositoryStub) UpdateStatus(ctx context.Context, userID entities.UserID, threadID uuid.UUID, params repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { if stub.updateStatus != nil { return stub.updateStatus(ctx, userID, threadID, params) } - return nil + return &entities.MessageThread{ID: threadID}, nil } func (stub *messageThreadRepositoryStub) UpdateAfterDeletedMessage(context.Context, repositories.MessageThreadDeletedUpdate) error { @@ -181,16 +181,10 @@ func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { threadID := uuid.New() isRead := false var captured repositories.MessageThreadStatusUpdate - var calls []string repository := &messageThreadRepositoryStub{ - updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) error { - calls = append(calls, "update") + updateStatus: func(_ context.Context, _ entities.UserID, _ uuid.UUID, params repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { captured = params - return nil - }, - load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { - calls = append(calls, "load") - return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: true}, nil + return &entities.MessageThread{ID: threadID, IsArchived: true, IsRead: false}, nil }, } @@ -207,16 +201,12 @@ func TestUpdateStatusChangesOnlyRequestedState(t *testing.T) { assert.False(t, captured.ReadAt.IsZero()) assert.True(t, thread.IsArchived) assert.False(t, thread.IsRead) - assert.Equal(t, []string{"load", "update"}, calls) } func TestUpdateStatusPreservesNotFoundCode(t *testing.T) { repository := &messageThreadRepositoryStub{ - load: func(context.Context, entities.UserID, uuid.UUID) (*entities.MessageThread, error) { - return &entities.MessageThread{ID: uuid.New()}, nil - }, - updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) error { - return stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") + updateStatus: func(context.Context, entities.UserID, uuid.UUID, repositories.MessageThreadStatusUpdate) (*entities.MessageThread, error) { + return nil, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, repositories.ErrCodeNotFound, "not found") }, } diff --git a/tests/read_receipts_test.go b/tests/read_receipts_test.go index f00035474..a65e7eb73 100644 --- a/tests/read_receipts_test.go +++ b/tests/read_receipts_test.go @@ -158,6 +158,9 @@ func TestMessageThreadReadReceipts(t *testing.T) { updated := markMessageThreadRead(ctx, t, thread.ID) assert.True(t, updated.IsRead) + assert.Equal(t, contact, updated.Contact) + require.NotNil(t, updated.LastMessageContent) + assert.Equal(t, "Unread inbound message", *updated.LastMessageContent) waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool { return thread.IsRead }) From b9c5971c2cb2f0eb412278c0a47301694746c62d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 14:39:32 +0300 Subject: [PATCH 21/22] refactor(api): update activity atomically Fold the unread watermark into the activity update so inbound events use one database query. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229 --- api/pkg/listeners/message_thread_listener.go | 4 +- .../listeners/message_thread_listener_test.go | 4 +- .../gorm_message_thread_repository.go | 52 +++++++------------ .../gorm_message_thread_repository_test.go | 36 +++++++++++++ .../repositories/message_thread_repository.go | 15 +++--- api/pkg/services/message_thread_service.go | 21 ++++---- .../services/message_thread_service_test.go | 22 ++++---- 7 files changed, 89 insertions(+), 65 deletions(-) diff --git a/api/pkg/listeners/message_thread_listener.go b/api/pkg/listeners/message_thread_listener.go index 71a9e05ed..47621296e 100644 --- a/api/pkg/listeners/message_thread_listener.go +++ b/api/pkg/listeners/message_thread_listener.go @@ -217,7 +217,7 @@ func (listener *MessageThreadListener) OnMessagePhoneReceived(ctx context.Contex Status: entities.MessageStatusReceived, Content: payload.Content, MessageID: payload.MessageID, - MarksUnread: true, + MarkAsUnread: true, EventTimestamp: event.Time(), } @@ -246,7 +246,7 @@ func (listener *MessageThreadListener) OnMessageCallMissed(ctx context.Context, Timestamp: payload.Timestamp, Content: "Missed phone call", MessageID: payload.MessageID, - MarksUnread: true, + MarkAsUnread: true, EventTimestamp: event.Time(), } if err := listener.service.UpdateThread(ctx, params); err != nil { diff --git a/api/pkg/listeners/message_thread_listener_test.go b/api/pkg/listeners/message_thread_listener_test.go index ef277c284..39a444cce 100644 --- a/api/pkg/listeners/message_thread_listener_test.go +++ b/api/pkg/listeners/message_thread_listener_test.go @@ -34,7 +34,7 @@ func TestMessageThreadListenerMarksInboundMessageUnread(t *testing.T) { err := routes[events.EventTypeMessagePhoneReceived](context.Background(), event) require.NoError(t, err) - assert.True(t, repository.activity.MarksUnread) + assert.True(t, repository.activity.MarkAsUnread) assert.Equal(t, event.Time(), repository.activity.EventTimestamp) } @@ -57,7 +57,7 @@ func TestMessageThreadListenerMarksMissedCallUnread(t *testing.T) { err := routes[events.MessageCallMissed](context.Background(), event) require.NoError(t, err) - assert.True(t, repository.activity.MarksUnread) + assert.True(t, repository.activity.MarkAsUnread) assert.Equal(t, "Missed phone call", repository.activity.Content) assert.Equal(t, event.Time(), repository.activity.EventTimestamp) } diff --git a/api/pkg/repositories/gorm_message_thread_repository.go b/api/pkg/repositories/gorm_message_thread_repository.go index 3a7996b5a..071f5cc79 100644 --- a/api/pkg/repositories/gorm_message_thread_repository.go +++ b/api/pkg/repositories/gorm_message_thread_repository.go @@ -45,6 +45,13 @@ func messageThreadActivityUpdates(params MessageThreadActivityUpdate) map[string if params.Unarchive { updates["is_archived"] = false } + if params.MarkAsUnread { + updates["is_read"] = gorm.Expr( + "CASE WHEN last_read_at < ? THEN ? ELSE is_read END", + params.EventTimestamp, + false, + ) + } return updates } @@ -145,37 +152,16 @@ func (repository *gormMessageThreadRepository) UpdateActivity(ctx context.Contex ctx, span := repository.tracer.Start(ctx) defer span.End() - err := repository.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - query := tx.Model(&entities.MessageThread{}). - Where("user_id = ?", params.UserID). - Where("id = ?", params.MessageThreadID) - - result := query.Updates(messageThreadActivityUpdates(params)) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return stacktrace.PropagateWithCode( - gorm.ErrRecordNotFound, - ErrCodeNotFound, - "thread with id [%s] not found", - params.MessageThreadID, - ) - } - - if !params.MarksUnread { - return nil - } - - return tx.Model(&entities.MessageThread{}). - Where("user_id = ?", params.UserID). - Where("id = ?", params.MessageThreadID). - Where("last_read_at < ?", params.EventTimestamp). - Update("is_read", false). - Error - }) - if err != nil { - return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot update message activity for thread [%s]", params.MessageThreadID)) + result := repository.db.WithContext(ctx). + Model(&entities.MessageThread{}). + Where("user_id = ?", params.UserID). + Where("id = ?", params.MessageThreadID). + Updates(messageThreadActivityUpdates(params)) + if result.Error != nil { + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, "cannot update message activity for thread [%s]", params.MessageThreadID)) + } + if result.RowsAffected == 0 { + return repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found", params.MessageThreadID)) } return nil @@ -199,10 +185,10 @@ func (repository *gormMessageThreadRepository) UpdateStatus( Where("id = ?", messageThreadID). Updates(messageThreadStatusUpdates(params)) if result.Error != nil { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, "cannot update status for thread [%s]", messageThreadID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(result.Error, "cannot update status for thread [%s] and user [%s]", messageThreadID, userID)) } if result.RowsAffected == 0 { - return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found", messageThreadID)) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(gorm.ErrRecordNotFound, ErrCodeNotFound, "thread with id [%s] not found for user with ID [%s]", messageThreadID, userID)) } return thread, nil diff --git a/api/pkg/repositories/gorm_message_thread_repository_test.go b/api/pkg/repositories/gorm_message_thread_repository_test.go index f634c2ce4..31dc43ed5 100644 --- a/api/pkg/repositories/gorm_message_thread_repository_test.go +++ b/api/pkg/repositories/gorm_message_thread_repository_test.go @@ -123,6 +123,42 @@ func TestMessageThreadActivityUpdatesOwnOnlyMessageColumns(t *testing.T) { assert.NotContains(t, updates, "last_read_at") } +func TestUpdateActivityMarksUnreadWithOneQuery(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 := NewGormMessageThreadRepository(logger, telemetry.NewOtelLogger("test", logger), db) + + err = repository.UpdateActivity(context.Background(), MessageThreadActivityUpdate{ + MessageThreadID: uuid.New(), + UserID: entities.UserID("user-id"), + Timestamp: time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC), + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + MarkAsUnread: true, + EventTimestamp: time.Date(2026, 7, 19, 10, 0, 1, 0, time.UTC), + }) + + require.NoError(t, err) + var updates []messageThreadTestStatement + for _, statement := range pool.statements { + if strings.HasPrefix(statement.query, `UPDATE "message_threads"`) { + updates = append(updates, statement) + } + } + require.Len(t, updates, 1) + assert.Contains(t, updates[0].query, `"is_read"=CASE WHEN last_read_at <`) +} + func TestMessageThreadDeletedUpdatesPreserveStatusType(t *testing.T) { messageID := uuid.New() content := "previous message" diff --git a/api/pkg/repositories/message_thread_repository.go b/api/pkg/repositories/message_thread_repository.go index 3efe3fb1e..e60931419 100644 --- a/api/pkg/repositories/message_thread_repository.go +++ b/api/pkg/repositories/message_thread_repository.go @@ -12,13 +12,14 @@ import ( type MessageThreadActivityUpdate struct { MessageThreadID uuid.UUID UserID entities.UserID - Timestamp time.Time - MessageID uuid.UUID - Content string - Status entities.MessageStatus - MarksUnread bool - EventTimestamp time.Time - Unarchive bool + // Timestamp controls thread activity ordering; EventTimestamp is the server-side unread watermark. + Timestamp time.Time + MessageID uuid.UUID + Content string + Status entities.MessageStatus + MarkAsUnread bool + EventTimestamp time.Time + Unarchive bool } type MessageThreadStatusUpdate struct { diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index bb57538bd..e3dc882c7 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -44,14 +44,15 @@ func NewMessageThreadService( // MessageThreadUpdateParams are parameters for updating a thread type MessageThreadUpdateParams struct { - Owner string - Status entities.MessageStatus - Contact string - Content string - UserID entities.UserID - MessageID uuid.UUID + Owner string + Status entities.MessageStatus + Contact string + Content string + UserID entities.UserID + MessageID uuid.UUID + // Timestamp controls thread activity ordering; EventTimestamp is the server-side unread watermark. Timestamp time.Time - MarksUnread bool + MarkAsUnread bool EventTimestamp time.Time } @@ -110,7 +111,7 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me MessageID: params.MessageID, Content: params.Content, Status: params.Status, - MarksUnread: params.MarksUnread, + MarkAsUnread: params.MarkAsUnread, EventTimestamp: params.EventTimestamp, } @@ -152,7 +153,7 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me } thread, err := service.repository.UpdateStatus(ctx, params.UserID, params.MessageThreadID, update) if err != nil { - return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot update message thread with id [%s]", params.MessageThreadID)) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "cannot update message thread with ID [%s] for user [%s]", params.MessageThreadID, params.UserID)) } return thread, nil @@ -211,7 +212,7 @@ func (service *MessageThreadService) createThread(ctx context.Context, params Me Contact: params.Contact, UserID: params.UserID, IsArchived: false, - IsRead: !params.MarksUnread, + IsRead: !params.MarkAsUnread, LastReadAt: now, Color: service.getColor(), LastMessageContent: ¶ms.Content, diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go index d31b98ddf..fc1e84313 100644 --- a/api/pkg/services/message_thread_service_test.go +++ b/api/pkg/services/message_thread_service_test.go @@ -98,12 +98,12 @@ func TestUpdateThreadPassesUnreadWatermarkForInboundActivity(t *testing.T) { Content: "hello", Status: entities.MessageStatusReceived, Timestamp: eventTimestamp, - MarksUnread: true, + MarkAsUnread: true, EventTimestamp: eventTimestamp, }) require.NoError(t, err) - assert.True(t, captured.MarksUnread) + assert.True(t, captured.MarkAsUnread) assert.Equal(t, eventTimestamp, captured.EventTimestamp) } @@ -131,7 +131,7 @@ func TestUpdateThreadPreservesReadStateForOutboundActivity(t *testing.T) { }) require.NoError(t, err) - assert.False(t, captured.MarksUnread) + assert.False(t, captured.MarkAsUnread) } func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { @@ -159,14 +159,14 @@ func TestCreateThreadSetsReadStateFromActivityDirection(t *testing.T) { service := newMessageThreadServiceForTest(repository) err := service.UpdateThread(context.Background(), MessageThreadUpdateParams{ - UserID: entities.UserID("user-id"), - Owner: "+18005550199", - Contact: "+18005550100", - MessageID: uuid.New(), - Content: "hello", - Status: entities.MessageStatusReceived, - Timestamp: time.Now().UTC(), - MarksUnread: test.marksUnread, + UserID: entities.UserID("user-id"), + Owner: "+18005550199", + Contact: "+18005550100", + MessageID: uuid.New(), + Content: "hello", + Status: entities.MessageStatusReceived, + Timestamp: time.Now().UTC(), + MarkAsUnread: test.marksUnread, }) require.NoError(t, err) From fa04695132fcafb246e92b21627c8084bc0f3cf6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 15:00:15 +0300 Subject: [PATCH 22/22] style: fix thread ui for unread threads --- web/app/components/MessageThread.vue | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 5e524de18..2a9834317 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -97,10 +97,13 @@ function onInstallApp() { color="primary" :to="{ name: 'threads-id', params: { id: thread.id } }" :active="threadsStore.threadId === thread.id" - :class="{ 'message-thread--unread': !thread.is_read }" > - -