progress/gaps-and-plan.md

Gaps & Plan

Consolidated issue tracker từ 01-create.md07-edge-cases.md. Ưu tiên theo impact + effort. Mỗi item link ngược về file gốc.

Legend

  • P0 — Blocker cho production real-world. Salon thật sẽ gặp trong tuần đầu vận hành.
  • P1 — Important trước launch công khai. Gap UX hoặc safety.
  • P2 — Nice-to-have / post-MVP.
  • P3 — Future / out-of-scope.

Checkbox:

  • [x] done + merged
  • [~] in-progress / partial
  • [ ] not started

P0 — Production blockers

[x] P0-1 · OWNER / ADMIN force cancel out-of-window

File: 03-cancel.md §3, §5, §7 · Real-world: case #4, #5, #6.

Vấn đề: booking.service.ts:804 block cả OWNER/ADMIN bằng !isSystem guard. Salon không cancel được khi emergency (staff ốm, khách gọi xin hủy sát giờ).

Fix:

  1. Sửa booking.service.ts:804:
    if (newStatus === CANCELLED && !isSystem && !isAdmin) {
      validateCancellationWindow(...);
    }
    
  2. Mở rộng booking.controller.ts:116 nhận body:
    @Body() body: { reason?: string; force?: boolean }
    
  3. Khi isAdmin && out-of-window → bắt buộc reason non-empty.
  4. Audit log: thêm action STATUS_CHANGE_FORCED với reason persistent.
  5. Frontend: modal "Reason" trước confirm force cancel (shadcn Dialog + Textarea).
  6. Test: unit test policy branch + e2e OWNER cancel out-of-window pass.

Effort: ~1 ngày. 1 API + 1 FE modal + tests.


[x] P0-2 · Deposit-required guard cho manual confirm

File: 02-happy-path.md §T1 · Real-world: case #3, #4 trong §8 01-create.md.

Vấn đề: STAFF có thể PENDING → CONFIRMEDdepositEnabled=true + Payment chưa AUTHORIZED. Booking CONFIRMED không deposit.

Fix:

  1. Thêm guard trong updateStatus cho PENDING → CONFIRMED:
    if (newStatus === CONFIRMED && !isSystem) {
      if (tenant.settings.depositEnabled && !isAdmin) {
        const payments = await paymentRepo.findByBookingId(id, tenantId);
        const hasValid = payments.some(p => p.status === AUTHORIZED || p.status === CAPTURED);
        if (!hasValid) throw UnprocessableEntityException('BOOKING_DEPOSIT_REQUIRED');
      }
    }
    
  2. Exception: payableTotal = 0 (full loyalty discount) → exempt guard (xem P2-9).
  3. OWNER override với { force, reason } body (dùng chung với P0-1).
  4. Audit log: CONFIRMED_WITHOUT_DEPOSIT flag.
  5. Test: STAFF blocked, OWNER force pass, loyalty-free case pass.

Effort: ~1 ngày. Gắn với P0-1 chung 1 PR.

Lưu ý: Cross-context dependency — Booking gọi PaymentRepo. Hoặc:

  • Option A (simpler): check booking.depositStatus projection (nhưng cần P1-9 trước).
  • Option B (current recommendation): Booking module inject PAYMENT_REPOSITORY port — OK về layering vì Payment đã expose port.

[x] P0-3 · Walk-in không emit BookingCreated

File: 01-create.md §4, §8 · Related: 06-loyalty-coupling.md case 1.

Vấn đề: Walk-in skip create event → loyalty không reserve redemption (nếu có), payment không init (OK cho walk-in vì pay tại salon), analytics miss events.

Decision cần: walk-in có nên emit BookingCreated không?

  • Pro: uniform event stream, loyalty auto-earn trên BookingCompleted vẫn chạy
  • Con: emit sẽ trigger Payment.initiate nếu depositEnabled → không phù hợp walk-in

Fix: emit BookingCreated với flag paymentMode: IN_PERSON (chung với P1-3):

  • Payment listener: skip initiate khi paymentMode = IN_PERSON.
  • Loyalty listener: vẫn chạy cho redemption + autoStamp.

Effort: ~0.5 ngày. Gắn với P1-3 chung PR.


P1 — Important before launch

[x] P1-1 · Customer self-cancel endpoint (public)

File: 03-cancel.md §7 · Real-world: case #1, #2. Shipped: 2026-04-24.

Shipped:

  1. POST /public/tenants/:slug/bookings/:id/cancel with @CustomerAuth() — requires a valid customer JWT, 403 for guests / mismatched owner, 404 when booking is scoped to another tenant.
  2. Performer sent to BookingService.updateStatus is { role: 'CUSTOMER', userId: customerId } so cancelledBy=CUSTOMER is emitted by buildStatusTransitionEvent and policy takes the customer branch.
  3. Cancellation-window guard kicks in automatically via the !isSystem && !isAdmin branch — customer out-of-window gets 422 BOOKING_CANCELLATION_TOO_LATE (P1-2 will add CTA).
  4. Frontend: account/BookingsSection exposes a red "Cancel" CTA on PENDING/CONFIRMED/ARRIVED rows behind a ConfirmDialog. API client teaches itself that /public/tenants/.../cancel is a customer-authed public path (refresh + retry via customer JWT).
  5. Tests: 6 new unit tests on PublicBookingController (happy path, guest booking, owner mismatch, tenant mismatch, window-guard passthrough, slug not found).

[x] P1-2 · "Contact salon" CTA khi out-of-window

File: 03-cancel.md §7, §8 case #2. Shipped: 2026-04-24.

Shipped:

  1. OutOfWindowDialog.tsx (web components/bookings/) — warning icon + policy message, booking reference card (salon name, booking ID, appointment time — customer copy/screenshot khi gọi salon), primary CTA link sang /b/{slug} để xem thông tin salon, secondary "Close".
  2. BookingsSection onError phân biệt: ApiError.code === 'BOOKING_CANCELLATION_TOO_LATE' → mở dialog; các lỗi khác giữ toast đỏ như cũ.
  3. State cancelling đổi từ {slug,id} sang full CustomerBooking để dialog có đủ thông tin mà không cần fetch thêm booking.
  4. Dialog lazy-fetch /public/tenants/:slug (staleTime 5min) để lấy settings.cancellationHours, message fallback khi không có.
  5. i18n: 8 new keys dưới customerAuth.outOfWindow.* (en + nb).

Deferred: tel:/mailto:/pre-filled compose — Tenant schema chưa có contactPhone/contactEmail field (chỉ OWNER User có). Sẽ add sau khi onboarding có bước capture salon contact, hoặc coi là P2 follow-up.


[x] P1-3 · paymentMode: IN_PERSON cho phone/walk-in booking

File: 01-create.md case #3 · Shipped 2026-04-24.

Implementation:

  1. BookingService.create derives paymentMode='IN_PERSON' when dto.source === BookingSource.PHONE and passes it to buildCreatedEvent. Walk-in path already set it (P0-3).
  2. OnBookingCreated listener already short-circuits on paymentMode === 'IN_PERSON' (P0-3 wiring) — no further change.
  1. Schema column deferred — the value only flows through the event payload and onBookingCreated is the sole consumer, so persisting on Booking adds no business value today. The admin BookingDrawer already exposes a source dropdown (ONLINE/PHONE/ADMIN), which the staff picks; the derivation is automatic from that.
  2. ADMIN source kept on the PSP path — admin-entered bookings may still send a payment link to the customer via SMS (future: explicit paymentMode override when admin needs to bypass).

Tests: 3 new unit tests in booking.service.spec.ts (PHONE→IN_PERSON, ONLINE→undefined, ADMIN→undefined).


[x] P1-4 · Distinguish transient vs permanent Payment failure + retry UI

File: 05-payment-driven.md §3 · 07-edge-cases.md §3a · Shipped 2026-04-25.

Implementation:

  1. Domain — new PaymentFailureKind enum (TRANSIENT | PERMANENT). Payment.markFailed(code, message, kind, at) stamps the kind onto PaymentFailedPayload. Not persisted on the aggregate (failed Payment is terminal so rehydration doesn't need it) — listeners read it once when the event fires.
  2. Webhook classificationprocess-webhook-inbox.service.ts maps Bambora payment.rejected / payment.rejected_capturePERMANENT (card declined / fraud / expired). TRANSIENT path is reserved for future provider-timeout / 5xx flows that don't yet exist (Bambora's adapter retries 5xx inside the call).
  3. Listener split (OnPaymentSettledNegativeListener):
    • PERMANENT + PENDING + failed-count < PAYMENT_RETRY_CAP (3) → keep PENDING; projection writes RETRY_PENDING; customer can retry with a different card.
    • PERMANENT + PENDING + failed-count >= cap → cancel with reason=PAYMENT_RETRY_EXHAUSTED.
    • TRANSIENT + PENDING → never cancels (provider-health, not customer-card).
    • PaymentExpired path unchanged from P1-11.
  4. ProjectionPaymentFailed (any kind) → BOOKING_DEPOSIT_STATUS.RetryPending. New value added to the const map; PAYMENT_FAILED remains as the legacy projection key for completeness but the listener never writes it (the listener owns "give up and cancel"; once cancelled, depositStatus is informational and UI keys off booking.status).
  5. Retry endpointPOST /public/tenants/:slug/bookings/:id/payment/retry (CustomerAuth). Mints a new Payment row with idempotencyKey = bk-${id}-retry-${attempt}, reusing the original Money + intent + captureMode so the customer pays the SAME deposit they originally saw (no settings re-derivation). Guests deferred to a future magic-link flow (docs/flows/status-matrix/05-payment-driven.md §retry-guests).
  6. Notifications (OnPaymentFailedRetryNotificationListener) — fires on PaymentFailed. Sends SMS (existing channel) + email (new channel via EmailProvider port; LOG impl ships, SMTP/SendGrid pluggable via EMAIL_PROVIDER env). Skips when booking already past PENDING (race), failed-count at cap (about-to-cancel), or no contact at all. New template BOOKING_PAYMENT_RETRY with {retryUrl} placeholder pointing to the customer portal deep link.
  7. Frontendaccount/BookingsSection: yellow "Retry payment" button when depositStatus=RETRY_PENDING && status=PENDING, fires POST /payment/retry and redirects to the new checkoutUrl. Email deep-link (?retry=<id>) auto-fires the mutation on landing and strips the param so a refresh doesn't churn. api-client.CUSTOMER_AUTHED_PUBLIC_PATHS extended to cover /payment/retry + /cancel-preview.

Tests: 9 new listener specs + 9 new retry-endpoint specs + 5 new processor email-channel specs + 4 new domain specs around failureKind. Existing markFailed callers updated to pass PaymentFailureKind.PERMANENT.

Deferred:

  • Guest retry (no JWT) — needs magic-link via email + transient bookingRef token. Tracked under P2-20 follow-up (TBD).
  • Real SMTP/SendGrid impl for EmailProviderLogEmailProvider is dev-only; production wiring waits on vendor sign-off.
  • Server-side automatic TRANSIENT retry (no customer click) — current spec only triggers retry on customer action.

[x] P1-5 · autoConfirm=true + depositEnabled=true guard

File: 01-create.md §3, 02-happy-path.md §T1 real-world case #4. Shipped: 2026-04-24.

Shipped (Option 1 — block the combo, per recommendation):

  1. New shared helper tenant-settings.validation.ts::validateSettingsCombination — runs on every settings write (create, update, onboarding) against the full merged state (not just the patch) so a per-field PATCH that would end up with both true still throws.
  2. Error code TENANT_SETTINGS_AUTOCONFIRM_DEPOSIT_CONFLICT (422 UnprocessableEntityException) + nb/en translations.
  3. Frontend BookingPolicyEditor wires the two toggles as mutually exclusive — each SwitchField gains a disabled prop (new) driven by the other's current value; description swaps to an explanation of why the toggle is locked ("Turn off Require deposit to enable this"). User never hits the API guard under normal flow.
  4. Tests: 6 pure-helper unit tests + 3 service integration tests (reject on update, reject when both flipped in one patch, allow disabling one side of a legacy row).

Why Option 1: deterministic, simple, no risky downgrade-to-PENDING listener logic, and keeps the negative payment listener's status !== PENDING short-circuit intact (was the whole reason for the bug).


[ ] P1-6 · Loyalty L4 lifecycle listeners

File: 06-loyalty-coupling.md §4, 04-no-show.md case #6.

Vấn đề: RESERVED redemption stuck forever nếu booking COMPLETED/CANCELLED/NO_SHOW vì L4 chưa ship.

Fix:

  1. on-booking-completed.loyalty.listener.ts — RESERVED → CONSUMED.
  2. on-booking-cancelled.loyalty.listener.ts — phân biệt pre-capture (restore) vs post-capture (forfeit).
  3. on-booking-no-show.loyalty.listener.ts — RESERVED → FORFEITED.
  4. Clawback POINTS_BASED: create LoyaltyPointTransaction{type: CLAWBACK, points: +N}.
  5. Stamp restore VISIT_BASED: re-insert stamp rows to cycle trước.
  6. Idempotent via status check.
  7. Migration dọn data orphan RESERVED cho booking terminal trước L4.

Effort: ~2 ngày. Listeners + migration + unit/e2e tests.


[ ] P1-7 · Reschedule rules + UX

File: 03-cancel.md §9.

Vấn đề: Update booking startTime không có guard khi out-of-window. Spec ngành: cho đổi đến X giờ trước.

Fix:

  1. Thêm setting rescheduleHours (separate từ cancellationHours, default = same).
  2. update endpoint check guard khi startTime changes + performer = CUSTOMER.
  3. FE: prefer "Reschedule" CTA trong out-of-window dialog (P1-2) thay vì chỉ "Contact salon".

Effort: ~1 ngày.


[x] P1-8 · Cancel preview: hiển thị refund amount

File: 03-cancel.md §10 admin UX · Shipped 2026-04-24.

Implementation:

  1. Pure helper buildCancellationPreview() sits on top of decideCancellationRefund in cancellation-refund-policy.ts — returns { decision, refundAmount, forfeitAmount, voidAmount, hoursUntilStart, withinWindow, cancellationWindowHours, currency }. Mirrors the FULL_REFUND → VOID defensive fallback from PaymentIntegrationService.onBookingCancelled so the preview matches what actually executes.
  2. Admin endpoint GET /bookings/:id/cancel-preview (STAFF/OWNER/ADMIN) — cancelledBy=SALON.
  3. Public endpoint GET /public/tenants/:slug/bookings/:id/cancel-preview (CustomerAuth) — same ownership check as POST cancel; cancelledBy=CUSTOMER.
  4. FE CancelPreviewDialog intercepts CANCELLED status change — admin (BookingList + BookingDrawer) and customer portal (account/BookingsSection) all fetch the preview before firing the mutation. Messages localized (en + nb) per decision: refund amount, void release, forfeit, no-action, no-payment.

Tests: 11 new specs in cancellation-refund-policy.spec.ts, 7 in booking.service.spec.ts, 1 in booking.controller.spec.ts, 4 in public-booking.controller.spec.ts.

Effort: ~1 ngày.


[x] P1-9 · depositStatus projection listener

File: 05-payment-driven.md §4, §5, §6. Shipped: 2026-04-24.

Shipped:

  1. Schema: nullable Booking.depositStatus TEXT column (enum-isation deferred to P2-8).
  2. Migration 20260424085654_add_booking_deposit_status_projection — schema + backfill from the latest DEPOSIT-intent Payment per booking in a single SQL upsert (DISTINCT ON (booking_id)).
  3. OnPaymentStateProjectionListener in booking/ — subscribes all 8 Payment events, maps via BOOKING_DEPOSIT_STATUS const (PENDING, AUTHORIZED, PAID, VOIDED, REFUNDED, PARTIALLY_REFUNDED, PAYMENT_FAILED, EXPIRED). Write via updateMany with where: { id, tenantId, NOT: { depositStatus } } — cross-tenant guard + idempotent re-delivery in a single statement. Ad-hoc payments (bookingId=null) skipped.
  4. Event payloads enriched: PaymentCaptured, PaymentRefunded, PaymentPartiallyRefunded, PaymentVoided now carry bookingId so the projection listener doesn't need a Payment lookup. Authorized / Failed / Expired / Initiated already had it.
  5. Tests: 13 new unit tests (happy path for each of the 8 events table-driven, subscription wiring, null-bookingId skip, cross-tenant guard, idempotent re-delivery, unrelated-event no-op).

Follow-ups: admin UI deposit badge (front-end work, unblocked), P1-10 customer refund/void notifications, P1-5 autoConfirm + depositEnabled guard (depends on this projection).


[x] P1-10 · Notification customer khi refund / void

File: 05-payment-driven.md §5, §6. Shipped: 2026-04-24.

Shipped:

  1. 3 new NotificationType: BOOKING_REFUNDED, BOOKING_PARTIALLY_REFUNDED, BOOKING_VOIDED.
  2. Norwegian SMS templates + formatMoney helper (minor-unit → "125 NOK" via toLocaleString('nb-NO')). {amount}{cumulativeAmount} placeholder reuse shared renderer.
  3. OnPaymentNotificationListener in core/booking/ — subscribes PaymentRefunded / PartiallyRefunded / Voided, resolves booking + phone (prefer linked customer, fallback to booking snapshot phone for guest bookings), enqueues via NotificationService. Cross-tenant guard, null-bookingId skip, no-phone skip (debug log), queue-failure swallow (fire-and-forget so outbox doesn't retry the real work).
  4. Tests: 11 new unit tests (3 happy-path per event type, template render, null-bookingId, cross-tenant, phone fallback, no-phone drop, queue failure soft-fail, unrelated-event no-op, subscription wiring).

Follow-up: email channel (SMS-only for now, per current NotificationProcessor). P2-7 NoShow notification reuses the same template pattern.


[x] P1-11 · PaymentExpired cho booking CONFIRMED (lead-time > 7d)

File: 07-edge-cases.md §2b · Shipped 2026-04-25.

Implementation (combined option 1 + option 2):

  1. Settings hard capvalidateSettingsCombination rejects depositEnabled=true && maxBookingDaysInAdvance > 7 with TENANT_SETTINGS_DEPOSIT_LEAD_TIME_CONFLICT. New constant DEPOSIT_LEAD_TIME_CAP_DAYS = 7 (the cap is the PSP's 7-day auth hold; bump if we move to a provider with longer holds). FE bookingPolicySchema.superRefine mirrors the rule (inline error maxBookingDaysDepositMax); error code translated to a friendly toast in en + nb.
  2. Listener auto-cancelOnPaymentSettledNegativeListener extended: PaymentExpired now also fires for CONFIRMED (PENDING was already handled). Calls bookingService.updateStatus(CANCELLED, role: SYSTEM, { reason: 'AUTHORIZATION_EXPIRED' }). SYSTEM bypasses isValidTransition + cancellation-window guard (no force=true needed). PaymentFailed keeps PENDING-only behaviour (CONFIRMED with a failed retry must keep its prior auth). ARRIVED/IN_PROGRESS/COMPLETED/CANCELLED skipped.
  3. Audit reason for SYSTEMBookingService.updateStatus now writes options.reason to the audit note column when called from SYSTEM (previously only forced admin overrides got a reason recorded).
  4. Notifications — existing BOOKING_CANCELLED SMS path fires automatically via the resulting BookingCancelled event; no new notification listener needed.

Tests: 5 new validator + tenant-service spec cases; 5 new listener spec cases (CONFIRMED+expired, ARRIVED skip, COMPLETED skip, plus undefined reason arg on existing PENDING paths).


P2 — Post-MVP nice-to-have

[ ] P2-1 · Phân biệt STAFF-cancel-for-customer vs SALON-cancel

File: 03-cancel.md §1, case #3.

Hiện tất cả non-CUSTOMER = 'SALON' → always full refund. Case staff cancel hộ khách muộn nên theo CUSTOMER policy (forfeit).

Fix: add body onBehalfOf: 'CUSTOMER' | 'SALON' trong cancel request. STAFF default = ON_BEHALF_OF_CUSTOMER, OWNER default = SALON.


[ ] P2-2 · VIP per-customer skip deposit

File: 01-create.md case #5.

Thêm TenantCustomer.trustLevel: 'NORMAL' | 'VIP'. Create booking với customerId VIP → depositAmount = 0 regardless of settings.


[ ] P2-3 · Walk-in emit BookingCreated with IN_PERSON flag

Đã cover trong P0-3 + P1-3.


[ ] P2-4 · UX cảnh báo lead-time > auth hold

File: 07-edge-cases.md §2b.

Settings form đã có warning. UX book customer: nếu chọn date > 7 ngày + depositEnabled → hiển thị inline "Deposit hold chỉ 7 ngày, nếu không có update từ salon sẽ cần re-auth."


[ ] P2-5 · Force cancel IN_PROGRESS với partial refund UX

File: 03-cancel.md case #7.

Khi OWNER force IN_PROGRESS → CANCELLED: hỏi "refund bao nhiêu?" + gọi RefundPaymentCommand với amount.


[ ] P2-6 · Customer no-show counter + display

File: 04-no-show.md §4.

Schema: TenantCustomer.noShowCount. Listener OnBookingMarkedNoShow increment. UI: badge "3 no-shows trong 6 tháng" trong customer detail + booking drawer để staff warning.


[ ] P2-7 · Notification email cho NoShow + void

File: 04-no-show.md §4, 05-payment-driven.md §6.

Templates nb/en cho NoShow apology + void confirmation.


[ ] P2-8 · Enum hóa depositStatus

File: 05-payment-driven.md §4.

Thay string bằng Prisma enum. Migration backfill. Tight coupling Booking schema với Payment lifecycle → cần cân nhắc.


[ ] P2-9 · payableTotal = 0 case

File: 06-loyalty-coupling.md case #1, 07-edge-cases.md §5a.

Khi loyalty discount = 100% → không có payment → booking stuck PENDING nếu autoConfirm=false.

Fix: onBookingCreated nếu depositAmount = 0 + depositEnabled=true → auto-publish BookingConfirmed via SYSTEM role.


[ ] P2-10 · Loyalty event contract + notification

File: 06-loyalty-coupling.md §8.

LoyaltyRedemptionConsumed + StampEarned + PointsEarned → notification "Bạn đã có stamp mới / +N điểm".


[ ] P2-11 · Cross-aggregate outbox ordering

File: 07-edge-cases.md §1a.

Nghiên cứu: cần guarantee ordering giữa PaymentAuthorized + BookingCancelled cho cùng booking? Hiện mỗi aggregate có ordering riêng. Risk assessment.


[ ] P2-12 · Admin ops UI: outbox dead letter + webhook retry

File: 07-edge-cases.md §2d, §7a.

/admin/ops/outbox + /admin/payments/webhooks. List + retrigger manual.


[ ] P2-13 · Reconciliation cron verify shipped

File: 07-edge-cases.md §3c.

Audit code: ReconciliationJob có chưa? Nếu chưa → implement (Flow 13 payment-flow.md).


[ ] P2-14 · Toggle depositEnabled=false migration

File: 07-edge-cases.md §4a.

Dialog cảnh báo N booking pending + lựa chọn "cancel all" / "keep and skip deposit".


[ ] P2-15 · Grandfather cancellationHours change

File: 07-edge-cases.md §4b.

Snapshot cancellationHours lên booking tại create. updateStatus dùng snapshot thay vì live settings.


[ ] P2-16 · Update booking items sau Payment captured

File: 07-edge-cases.md §5b.

Policy: block edit items sau capture, hoặc auto-refund difference. Decision cần.


[ ] P2-17 · Optimistic locking booking edits

File: 07-edge-cases.md §6a.

If-Match: updatedAt header.


[ ] P2-18 · Outbox retention job

File: 07-edge-cases.md §7b.

Cron xóa published_at < now() - 30d.


[ ] P2-19 · Manual payment reconcile endpoint

File: 07-edge-cases.md §7d.

PATCH /admin/payments/:id/reconcile để fix aggregate corrupt.


P3 — Future / out-of-scope

  • Cron auto-mark NoShow sau grace (no manual click).
  • Loyalty L5 customer portal UI.
  • Loyalty L6 admin redemption picker.
  • POS integration (gate M2 guard).
  • Multi-location (Organization layer).
  • SMS retry payment link.

Sprint 1 (1 tuần) — unblock real salon

  1. P0-1 + P0-2 + P0-3 (combined PR) — force cancel + deposit guard + walk-in event
  2. P1-1 customer cancel endpoint
  3. P1-2 out-of-window dialog
  4. P1-9 depositStatus projection (enabler cho P1-5, P1-10)

Sprint 2 (1 tuần) — stability + UX

  1. P1-3 paymentMode IN_PERSON
  2. P1-4 payment retry
  3. P1-5 autoConfirm+deposit conflict block
  4. P1-11 7-day lead-time enforcement
  5. P1-10 refund notifications
  6. P1-8 cancel preview

Sprint 3 (1 tuần) — loyalty L4

  1. P1-6 L4 listeners (COMPLETED + CANCELLED + NO_SHOW)
  2. P1-7 reschedule guard

Post-MVP (ad-hoc)

  • P2 items theo priority của business.

Tracking

Mỗi item resolved → update:

  1. File gốc: đổi [!]/[ ] thành [x].
  2. File này: check box của item.
  3. Git commit message: fix(status-matrix): resolve P0-1 force cancel out-of-window.
  4. Re-run npx gitnexus analyze sau merge để refresh index.