Support and notifications
Tickets and after-sales claims with their items and resolution, regional WhatsApp support, announcements, the notification inbox, delivery log, templates and rules.
Support tickets and after-sales claims (cahier §2.1, §14.1 — SUP), the regional WhatsApp support of the reseller apps, back-office announcements and the notification system (cahier §8 — NOT). Prisma file: support.prisma.
After-sales in V1 (SUP-04): a reseller opens a ticket of type AFTER_SALES on one of their orders, lists the affected lines and quantities (TicketItem) with the issue (AfterSalesIssue) and photos. The back-office resolves it with one AfterSalesResolution, and the ticket points to what the resolution created:
| Resolution | Creates | Ticket column |
|---|---|---|
REPLACEMENT | a free Order (channel BACK_OFFICE, total 0) that goes through the normal confirmation and delivery | replacementOrderId |
RETURN | a ReturnRequest; its reception re-enters stock and issues a credit note | returnRequestId |
CREDIT_NOTE | a CreditNote without physical return | creditNoteId |
REPAIR | nothing (repair under warranty, noted in the conversation) | — |
REJECTED | nothing; status REJECTED with rejectionReason | — |
Regional WhatsApp (SUP-05, ADM-08): SupportRegion groups wilayas and holds the WhatsApp number and hours; the button opens the region of the signed-in reseller's wilaya, else of the device position (nearest commune centroid), else of a picked wilaya, else the default region.
Notifications: one Notification per user and event (the in-app inbox), one NotificationDelivery per channel attempt (push, SMS, e-mail, WhatsApp); texts come from NotificationTemplate and routing from NotificationRule (the notifications matrix).
Tickets and after-sales
Ticket
A reseller support request (SUP-01/02) — including after-sales claims (type = AFTER_SALES, SUP-04) on items of an order, handled from diagnosis to resolution. Table tickets · written by support (tickets.*, admin.tickets.*; admin.tickets.resolve for after-sales).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
number | String @db.VarChar(20) | — | — | Unique. TKT-YYYY-NNNNNN from NumberSequence TICKET. |
resellerId | String @db.Uuid | — | — | FK → Reseller · Restrict. |
openedById | String @db.Uuid | — | — | FK → User · Restrict. |
type | TicketType | — | — | — |
subject | String | — | — | — |
status | TicketStatus | — | OPEN | — |
statusChangedAt | DateTime | — | now() | — |
priority | TicketPriority | — | NORMAL | — |
orderId | String @db.Uuid | yes | — | FK → Order · Restrict. Related order; required for AFTER_SALES (CHECK) and must be the reseller's. |
assignedToId | String @db.Uuid | yes | — | FK → User · Restrict. |
lastMessageAt | DateTime | yes | — | Cache for queue sorting. |
firstResponseAt | DateTime | yes | — | First public staff reply (support KPI). |
resolution | AfterSalesResolution | yes | — | After-sales only: how the claim was settled (admin.tickets.resolve). |
resolvedAt | DateTime | yes | — | — |
resolvedById | String @db.Uuid | yes | — | FK → User · Restrict. support.validate holder. |
replacementOrderId | String @db.Uuid | yes | — | Unique. FK → Order · Restrict. REPLACEMENT: the free order created by the resolution. |
returnRequestId | String @db.Uuid | yes | — | Unique. FK → ReturnRequest · Restrict. RETURN: the return created by the resolution. |
creditNoteId | String @db.Uuid | yes | — | Unique. FK → CreditNote · Restrict. CREDIT_NOTE: credit note without physical return. |
rejectionReason | String | yes | — | Required for resolution REJECTED (out of warranty, misuse…); status becomes REJECTED. |
closedAt | DateTime | yes | — | — |
rating | Int | yes | — | 1–5, optional at tickets.close. |
ratingComment | String | yes | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
reseller→ Reseller (resellerId,onDelete: Restrict) ·openedBy→ User (openedById,onDelete: Restrict) ·order→ Order (orderId,onDelete: Restrict) ·assignedTo→ User (assignedToId,onDelete: Restrict) ·resolvedBy→ User (resolvedById,onDelete: Restrict) ·replacementOrder→ Order (replacementOrderId,onDelete: Restrict) ·returnRequest→ ReturnRequest (returnRequestId,onDelete: Restrict) ·creditNote→ CreditNote (creditNoteId,onDelete: Restrict). - Referenced by: TicketItem.
ticketId, TicketMessage.ticketId. - Keys & indexes:
numberunique ·replacementOrderIdunique ·returnRequestIdunique ·creditNoteIdunique ·@@index([status, priority, lastMessageAt])·@@index([resellerId, createdAt(sort: Desc)])·@@index([assignedToId, status])·@@index([type, status])·@@index([orderId]). - Enums: TicketType, TicketStatus, TicketPriority (proposed), AfterSalesResolution.
- Lifecycle: TicketStatus per the lifecycles page; after-sales adds
IN_PROGRESS → RESOLVEDwith a resolution (REPLACEMENT,RETURN,CREDIT_NOTE,REPAIR) orIN_PROGRESS → REJECTEDwith a reason. The resolution, its linked record and the status change are written in one transaction. - History: Status, assignment, priority and resolution changes → AuditLog; every message → TicketMessage; the reseller timeline gets an Interaction
CLAIM. - Derived, not stored: SLA timers (time to first response, time to resolution) — from
createdAt,firstResponseAt,resolvedAt.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE tickets ADD CONSTRAINT tickets_after_sales_order CHECK (type <> 'AFTER_SALES' OR order_id IS NOT NULL);
ALTER TABLE tickets ADD CONSTRAINT tickets_resolution CHECK (
(resolution IS NULL OR type = 'AFTER_SALES')
AND (resolution IS DISTINCT FROM 'REPLACEMENT' OR replacement_order_id IS NOT NULL)
AND (resolution IS DISTINCT FROM 'RETURN' OR return_request_id IS NOT NULL)
AND (resolution IS DISTINCT FROM 'CREDIT_NOTE' OR credit_note_id IS NOT NULL)
AND (resolution IS DISTINCT FROM 'REJECTED' OR (rejection_reason IS NOT NULL AND status IN ('REJECTED', 'CLOSED'))));
ALTER TABLE tickets ADD CONSTRAINT tickets_rating CHECK (rating IS NULL OR rating BETWEEN 1 AND 5);Example row:
{
"id": "01a1bfc0-1900-71ab-83c8-a3cc1a5d0ccd",
"number": "TKT-2026-000045",
"resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
"openedById": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"type": "AFTER_SALES",
"subject": "Câbles X88 qui ne chargent pas",
"status": "RESOLVED",
"statusChangedAt": "2026-11-10T14:00:00.000Z",
"priority": "HIGH",
"orderId": "01a1ac4b-a100-74df-8237-614783cfeacd",
"assignedToId": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"lastMessageAt": "2026-11-10T14:00:00.000Z",
"firstResponseAt": "2026-11-09T11:20:00.000Z",
"resolution": "RETURN",
"resolvedAt": "2026-11-10T14:00:00.000Z",
"resolvedById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"replacementOrderId": null,
"returnRequestId": "01a1c4bd-4220-7f7e-bf49-85cc6160afd3",
"creditNoteId": null,
"rejectionReason": null,
"closedAt": null,
"rating": null,
"ratingComment": null,
"createdAt": "2026-11-09T10:00:00.000Z",
"updatedAt": "2026-11-10T14:00:00.000Z"
}TicketItem
An order line claimed in an after-sales ticket, with the quantity, the issue and photos (SUP-04). Table ticket_items · written by support (tickets.create for AFTER_SALES).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
ticketId | String @db.Uuid | — | — | FK → Ticket · Cascade. |
orderItemId | String @db.Uuid | — | — | FK → OrderItem · Restrict. Must belong to Ticket.orderId. |
quantity | Int | — | — | > 0 and ≤ the delivered quantity of the line. |
issue | AfterSalesIssue | — | — | — |
description | String | yes | — | — |
photoFileIds | String[] @db.Uuid | — | [] | TICKET_ATTACHMENT photos of the claimed items (no FK: array). |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
ticket→ Ticket (ticketId,onDelete: Cascade) ·orderItem→ OrderItem (orderItemId,onDelete: Restrict). - Keys & indexes:
@@unique([ticketId, orderItemId]). - Enums: AfterSalesIssue.
- A
RETURNresolution copies the claimed lines and quantities into ReturnItem rows;REPLACEMENTcopies them into the free order's lines.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE ticket_items ADD CONSTRAINT ticket_items_quantity CHECK (quantity > 0);Example row:
{
"id": "01a1bfc0-1900-72fe-ba51-98ed73082185",
"ticketId": "01a1bfc0-1900-71ab-83c8-a3cc1a5d0ccd",
"orderItemId": "01a1ac4b-a100-7f7e-bf8e-c3ea35df01d8",
"quantity": 2,
"issue": "DEFECTIVE",
"description": "Le téléphone ne charge pas avec ces deux câbles.",
"photoFileIds": [
"01a19b9e-fb9e-7637-b9d4-0ea1412c6022"
],
"createdAt": "2026-11-09T10:00:00.000Z",
"updatedAt": "2026-11-09T10:00:00.000Z"
}TicketMessage
A message of a ticket conversation, public or internal (SUP-02). Table ticket_messages · written by support.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
ticketId | String @db.Uuid | — | — | FK → Ticket · Cascade. |
authorId | String @db.Uuid | — | — | FK → User · Restrict. |
body | String | — | — | — |
isInternal | Boolean | — | false | Internal notes are never returned to the reseller and never change the status. |
isQuestion | Boolean | — | false | Staff reply that asks the reseller something → status WAITING_RESELLER. |
attachmentFileIds | String[] @db.Uuid | — | [] | TICKET_ATTACHMENT files (no FK: array). |
createdAt | DateTime | — | now() | — |
- Relations:
ticket→ Ticket (ticketId,onDelete: Cascade) ·author→ User (authorId,onDelete: Restrict). - Keys & indexes:
@@index([ticketId, createdAt]). - Immutable (no edits, no
updatedAt). A reseller reply emitsticket.repliedon the staff side; a staff public reply reaches the reseller onevents.streamand by push.
Example row:
{
"id": "01a1c009-5700-7d7b-b8ee-08fc5aeda2fb",
"ticketId": "01a1bfc0-1900-71ab-83c8-a3cc1a5d0ccd",
"authorId": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"body": "Bonjour, pouvez-vous nous renvoyer les deux câbles ? Nous vous émettrons un avoir.",
"isInternal": false,
"isQuestion": true,
"attachmentFileIds": [],
"createdAt": "2026-11-09T11:20:00.000Z"
}Support regions
SupportRegion
A support region grouping wilayas, with the WhatsApp number and hours behind the fixed WhatsApp button of both reseller apps (SUP-05, ADM-08). Table support_regions · written by support (admin.supportRegions.crud, BO-70; read by support.whatsapp).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
code | String @db.VarChar(20) | — | — | Unique. |
name | String | — | — | — |
whatsappNumber | String @db.VarChar(16) | — | — | E.164; the API builds https://wa.me/<digits> from it. |
phone | String @db.VarChar(16) | yes | — | Voice support number of the region, if different. |
hours | String | — | — | Opening hours as displayed (FR), e.g. Dim–Jeu 08:30–17:00. |
hoursAr | String | yes | — | — |
isDefault | Boolean | — | false | Fallback region when no wilaya can be determined; exactly one. |
isActive | Boolean | — | true | — |
sortOrder | Int | — | 0 | Order in the wilaya/region picker. |
archivedAt | DateTime | yes | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations: none outgoing.
- Referenced by: Reseller.
supportRegionId, SupportRegionWilaya.supportRegionId. - Keys & indexes:
codeunique. - Region resolution (
support.whatsapp, SUP-05): signed in →Reseller.supportRegionId(from the account's wilaya); otherwise the request'slat/lng→ nearest Commune centroid → its wilaya → SupportRegionWilaya; otherwise an explicitwilayaCode; otherwise the default region. The response carries the number, thewa.melink and the hours.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
CREATE UNIQUE INDEX support_regions_one_default ON support_regions (is_default) WHERE is_default AND archived_at IS NULL;Example row:
{
"id": "01a17c96-7e80-7bd8-b8aa-2b5b2d991956",
"code": "CENTRE",
"name": "Centre (Alger, Blida, Boumerdès, Tipaza)",
"whatsappNumber": "+213770123456",
"phone": null,
"hours": "Dim–Jeu 08:30–17:00 · Sam 09:00–13:00",
"hoursAr": null,
"isDefault": true,
"isActive": true,
"sortOrder": 1,
"archivedAt": null,
"createdAt": "2026-10-27T09:00:00.000Z",
"updatedAt": "2026-10-27T09:00:00.000Z"
}SupportRegionWilaya
Assigns a wilaya to a support region. Table support_region_wilayas · written by support.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
supportRegionId | String @db.Uuid | — | — | PK. FK → SupportRegion · Cascade. Part of the composite PK. |
wilayaCode | String @db.VarChar(2) | — | — | PK. Unique. FK → Wilaya.code · Restrict. Part of the composite PK. Unique: a wilaya belongs to exactly one region. |
createdAt | DateTime | — | now() | — |
- Relations:
supportRegion→ SupportRegion (supportRegionId,onDelete: Cascade) ·wilaya→ Wilaya (wilayaCode,onDelete: Restrict). - Keys & indexes:
@@id([supportRegionId, wilayaCode])·wilayaCodeunique. - Link row: editing a region's wilayas replaces its rows and re-derives
Reseller.supportRegionIdfor the resellers of the moved wilayas in the same transaction (AuditLog keeps the diff). Seeded so that all 69 wilayas are covered.
Example row:
{
"supportRegionId": "01a17c96-7e80-7bd8-b8aa-2b5b2d991956",
"wilayaCode": "16",
"createdAt": "2026-10-27T09:00:00.000Z"
}Announcements
Announcement
A message from the back-office to delegates and/or resellers by target (Messages, NOT-07). Table announcements · written by support (admin.announcements.*, BO-57); notifications fans it out.
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
title | String | — | — | — |
body | String | — | — | French body. |
bodyAr | String | yes | — | Arabic body, shown to Arabic readers when set (SRV-41). |
imageFileId | String @db.Uuid | yes | — | FK → File · SetNull. Purpose ANNOUNCEMENT_IMAGE. |
targetUserTypes | UserType[] | — | [] | Audience kinds; at least one (CHECK). |
targetCustomerTypes | CustomerType[] | — | [] | Resellers of these customer types only; empty = every type (SRV-41). |
targetZoneIds | String[] @db.Uuid | — | [] | Empty = all zones (no FK: array). |
targetWilayaCodes | String[] | — | [] | Empty = all wilayas. |
targetSegmentIds | String[] @db.Uuid | — | [] | Resellers only; empty = all segments. |
sendPush | Boolean | — | true | — |
sendSms · sendEmail | Boolean | — | false | API channels SMS / EMAIL (messages only, SRV-42): announcement.published adds the channel to its rule's; SMS go through the gateway of D-13 (simulated until it is configured), e-mail through SMTP. |
smsText | String @db.VarChar(160) | yes | — | The SMS copy, required with sendSms. |
targetUserIds | String[] @db.Uuid | — | [] | Named users (API audience.userIds); empty = everyone matching the other criteria, else only these (ANDed with the rest). No FK: array. |
scheduledAt | DateTime | yes | — | — |
publishedAt | DateTime | yes | — | — |
expiresAt | DateTime | yes | — | — |
archivedAt | DateTime | yes | — | — |
createdById | String @db.Uuid | — | — | FK → User · Restrict. |
kind | AnnouncementKind | — | MESSAGE | MESSAGE, COMMERCIAL_PUSH (SN-23), NEW_CATALOGUE (SN-19). |
group | NotificationGroup | yes | — | Notification Centre group of the fan-out: PROMOTIONS (commercial push), NEW_PRODUCTS (catalogue), URGENT (urgent message), else null. |
productId | String @db.Uuid | yes | — | FK → Product · SetNull. What a commercial push opens (one of product, promotion, category, brand). |
promotionId | String @db.Uuid | yes | — | FK → Promotion · SetNull. |
categoryId | String @db.Uuid | yes | — | FK → Category · SetNull. |
brandId | String @db.Uuid | yes | — | FK → Brand · SetNull. |
fileId | String @db.Uuid | yes | — | FK → File · SetNull. The catalogue PDF (CATALOG_PDF) of a NEW_CATALOGUE. |
actionLabel | String @db.VarChar(40) | yes | — | Button label; null = the kind's default. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
imageFile→ File (imageFileId,onDelete: SetNull) ·createdBy→ User (createdById,onDelete: Restrict) ·product,promotion,category,brand,file(allonDelete: SetNull). - Referenced by: AnnouncementRead.
announcementId. - Keys & indexes:
@@index([publishedAt])·@@index([scheduledAt]). - Enums: UserType, CustomerType, AnnouncementKind, NotificationGroup (smart notifications).
- Derived, not stored: status — draft (no
publishedAt, noscheduledAt), scheduled, published, expired (expiresAtpassed), archived: from the timestamps, no enum.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
ALTER TABLE announcements ADD CONSTRAINT announcements_audience CHECK (cardinality(target_user_types) >= 1);Example row:
{
"id": "01a19b45-b800-79d2-9a2f-13ba8dab597d",
"title": "Nouvelle gamme ACEFAST disponible",
"body": "Les chargeurs ACEFAST GaN 65 W arrivent cette semaine. Présentez-les en priorité.",
"imageFileId": null,
"targetUserTypes": [
"DELEGATE"
],
"targetZoneIds": [],
"targetWilayaCodes": [],
"targetSegmentIds": [],
"sendPush": true,
"scheduledAt": null,
"publishedAt": "2026-11-02T08:00:00.000Z",
"expiresAt": "2026-11-30T00:00:00.000Z",
"archivedAt": null,
"createdById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
"createdAt": "2026-11-02T08:00:00.000Z",
"updatedAt": "2026-11-02T08:00:00.000Z"
}AnnouncementRead
A user's acknowledgement of an announcement. Table announcement_reads · written by notifications (announcements.markRead).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
announcementId | String @db.Uuid | — | — | PK. FK → Announcement · Cascade. Part of the composite PK. |
userId | String @db.Uuid | — | — | PK. FK → User · Cascade. Part of the composite PK. |
readAt | DateTime | — | now() | Creation time of the row (this table has no createdAt). |
- Relations:
announcement→ Announcement (announcementId,onDelete: Cascade) ·user→ User (userId,onDelete: Cascade). - Keys & indexes:
@@id([announcementId, userId])·@@index([userId]).
Example row:
{
"announcementId": "01a19b45-b800-79d2-9a2f-13ba8dab597d",
"userId": "01a1a024-7d34-7424-adb6-099ecaff5095",
"readAt": "2026-11-02T09:12:00.000Z"
}Notifications
Notification
An entry of a user's in-app inbox (NOT-01); its push/SMS/e-mail copies are NotificationDelivery rows. Table notifications · written by notifications (from outbox events; notifications.markRead*).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
userId | String @db.Uuid | — | — | FK → User · Cascade. |
category | NotificationCategory | — | — | — |
eventKey | String @db.VarChar(64) | — | — | Business event, e.g. order.status_changed. |
title | String | — | — | Rendered from the template in the user's locale. |
body | String | — | — | — |
data | Json | — | {} | Deep link and context: route, params, entity ids; the same object is sent as the FCM data payload. |
group | NotificationGroup | yes | — | Notification Centre section (NOT-10); null for delegates and staff and for older items. |
actionLabel | String @db.VarChar(40) | yes | — | The action button (NOT-10), e.g. « Commander maintenant ». |
actionRoute | String | yes | — | The button's deep link; data.route keeps the same link for older apps. |
readAt | DateTime | yes | — | — |
expiresAt | DateTime | yes | — | Purge date (retention, proposal 12 months). |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
user→ User (userId,onDelete: Cascade). - Referenced by: PaymentReminder.
notificationId, NotificationDelivery.notificationId. - Keys & indexes:
@@index([userId, createdAt(sort: Desc)])·@@index([userId, group, createdAt(sort: Desc)])(inbox tabs) ·@@index([expiresAt]). - Enums: NotificationCategory.
- Creation emits
notification.createdonevents.streamso an open reseller app or page refreshes its badge live.
Constraints and indexes Prisma cannot express (raw SQL in the migration):
-- unread badge (notifications.unreadCount)
CREATE INDEX notifications_unread ON notifications (user_id) WHERE read_at IS NULL;Example row:
{
"id": "01a1ac74-d7c8-7c97-a660-3603cea892ac",
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"category": "TRANSACTIONAL",
"eventKey": "order.status_changed",
"title": "Commande validée",
"body": "Votre commande CMD-2026-000158 a été validée.",
"data": {
"route": "orders/detail",
"params": {
"orderId": "01a1ac4b-a100-74df-8237-614783cfeacd"
},
"orderNumber": "CMD-2026-000158",
"status": "VALIDATED"
},
"readAt": "2026-11-05T16:30:00.000Z",
"expiresAt": "2027-11-05T16:05:01.000Z",
"createdAt": "2026-11-05T16:05:01.000Z",
"updatedAt": "2026-11-05T16:30:00.000Z"
}NotificationDelivery
One attempt to deliver a notification on one channel — the delivery log of admin.notifications.log (NOT-04). Table notification_deliveries · written by notifications (worker).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
notificationId | String @db.Uuid | yes | — | FK → Notification · SetNull. null for messages without inbox entry (OTP SMS). |
userId | String @db.Uuid | yes | — | FK → User · SetNull. |
channel | NotificationChannel | — | — | — |
status | NotificationDeliveryStatus | — | QUEUED | — |
eventKey | String @db.VarChar(64) | — | — | — |
templateId | String @db.Uuid | yes | — | FK → NotificationTemplate · SetNull. |
deviceId | String @db.Uuid | yes | — | FK → Device · SetNull. Target device for PUSH. |
destination | String | yes | — | Phone (E.164) or e-mail for SMS/e-mail; masked in the BO log. |
provider | String @db.VarChar(30) | yes | — | fcm, the SMS gateway key, smtp… |
providerMessageId | String | yes | — | — |
attempts | Int | — | 0 | — |
errorCode | String @db.VarChar(60) | yes | — | — |
errorMessage | String | yes | — | — |
sentAt | DateTime | yes | — | — |
deliveredAt | DateTime | yes | — | — |
failedAt | DateTime | yes | — | — |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
notification→ Notification (notificationId,onDelete: SetNull) ·user→ User (userId,onDelete: SetNull) ·template→ NotificationTemplate (templateId,onDelete: SetNull) ·device→ Device (deviceId,onDelete: SetNull). - Keys & indexes:
@@index([status, createdAt])·@@index([userId, createdAt])·@@index([notificationId])·@@index([provider, providerMessageId]). - Enums: NotificationChannel, NotificationDeliveryStatus (proposed).
Example row:
{
"id": "01a1ac74-dbb0-7272-8f3e-c3bce69763e7",
"notificationId": "01a1ac74-d7c8-7c97-a660-3603cea892ac",
"userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
"channel": "PUSH",
"status": "SENT",
"eventKey": "order.status_changed",
"templateId": "01a15db0-5680-73a9-8d1e-e4fb0c9693b3",
"deviceId": "01a1aab7-9380-72b4-afca-8f9ed2d4913e",
"destination": null,
"provider": "fcm",
"providerMessageId": "projects/hoco-prod/messages/0:1730822702%31bd1c9631bd1c96",
"attempts": 1,
"errorCode": null,
"errorMessage": null,
"sentAt": "2026-11-05T16:05:03.000Z",
"deliveredAt": null,
"failedAt": null,
"createdAt": "2026-11-05T16:05:02.000Z",
"updatedAt": "2026-11-05T16:05:03.000Z"
}NotificationTemplate
Localised text of one event on one channel (NOT-04). Table notification_templates · written by notifications (seeded; admin.notifications.templates, BO-58).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
eventKey | String @db.VarChar(64) | — | — | — |
channel | NotificationChannel | — | — | — |
locale | String @db.VarChar(2) | — | — | fr, ar or en; fr must exist for every event and channel. |
title | String | yes | — | Push and in-app title, e-mail subject. |
body | String | — | — | Mustache-style placeholders such as {{order.number}} from a typed variable list per event. |
group | NotificationGroup | yes | — | Notification Centre group the event's items go to (smart notifications). |
actionLabel | String @db.VarChar(40) | yes | — | Default action button label of the event. |
isActive | Boolean | — | true | — |
updatedById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
updatedBy→ User (updatedById,onDelete: SetNull). - Referenced by: NotificationDelivery.
templateId. - Keys & indexes:
@@unique([eventKey, channel, locale]). - Enums: NotificationChannel.
Example row:
{
"id": "01a15db0-5680-73a9-8d1e-e4fb0c9693b3",
"eventKey": "order.status_changed",
"channel": "PUSH",
"locale": "fr",
"title": "Commande {{order.statusLabel}}",
"body": "Votre commande {{order.number}} est maintenant « {{order.statusLabel}} ».",
"isActive": true,
"updatedById": null,
"createdAt": "2026-10-21T09:00:00.000Z",
"updatedAt": "2026-10-21T09:00:00.000Z"
}NotificationRule
One row of the event → recipient → channels matrix (NOT-04, cahier §8). Table notification_rules · written by notifications (seeded from the notifications matrix; admin.notifications.rules).
| Field | Prisma type | Null | Default | Constraints / notes |
|---|---|---|---|---|
id | String @db.Uuid | — | uuid(7) | PK. |
eventKey | String @db.VarChar(64) | — | — | — |
recipient | String @db.VarChar(40) | — | — | Recipient selector: RESELLER, ASSIGNED_DELEGATE, SOURCE_DELEGATE, ASSIGNEE, ROLE:<RoleKey>. |
channels | NotificationChannel[] | — | [] | Channels used for that recipient. |
category | NotificationCategory | — | — | Preference category the user can opt out of (not TRANSACTIONAL/SECURITY). |
isEnabled | Boolean | — | true | — |
isMandatory | Boolean | — | false | Ignores user preferences (security, order status). |
priority | Int | — | 0 | Smart notifications: the higher wins when the caps leave room for one push only (anti-fatigue). |
mergeWindowHours | Int | — | 0 | Events of the same key for a user within this window merge into one item (.grouped template). |
updatedById | String @db.Uuid | yes | — | FK → User · SetNull. |
createdAt | DateTime | — | now() | — |
updatedAt | DateTime | — | @updatedAt | — |
- Relations:
updatedBy→ User (updatedById,onDelete: SetNull). - Keys & indexes:
@@unique([eventKey, recipient]). - Enums: NotificationChannel, NotificationCategory.
Example row:
{
"id": "01a15db0-5680-74ba-81f7-6eea77d9740b",
"eventKey": "order.status_changed",
"recipient": "RESELLER",
"channels": [
"IN_APP",
"PUSH"
],
"category": "TRANSACTIONAL",
"isEnabled": true,
"isMandatory": true,
"updatedById": null,
"createdAt": "2026-10-21T09:00:00.000Z",
"updatedAt": "2026-10-21T09:00:00.000Z"
}