HOCO B2B
SRSDatabase design

CRM

Resellers and prospects with the cahier's minimum data set, addresses, documents, photos, lifecycle and assignment history, timeline, follow-up tasks and segments.

The CRM keeps one record per point of sale from the first prospecting visit to the active customer (cahier §4.2, CRM requirements). A field prospect, a self sign-up, a back-office entry and an imported line all become a Reseller; the main phone number (E.164) is the duplicate key on every path (CRM-03), and a reseller who signs up with the phone of an existing prospect claims that record (AUTH-05). Prisma file: crm.prisma.

Since the 17 Oct decisions the reseller also carries its customer type (SUPER_GROS, DEMI_GROS, DETAIL) — declared at sign-up, reviewed at validation, then changed only by the back-office (AUTH-11) — and its support region, derived from its wilaya.

Minimum data of cahier §15.5 and where it lives:

§15.5 itemColumn / table
Magasin · ResponsablestoreName · ownerName
Téléphone · WhatsApp · Emailphone (unique) · whatsapp · email
Wilaya · Commune · AdressewilayaCode · communeId · address, landmark
GPSlat, lng, gpsAccuracyMeters, gpsCapturedAt
Type · PotentielbusinessTypeCode (ReferenceItem BUSINESS_TYPE) · potential
DéléguéassignedDelegateId (+ createdByUserId for the creating delegate)
Photos · DocumentsResellerPhoto · ResellerDocument
Statutstatus (ResellerStatus) and validationStatus

Point of sale

Reseller

A point of sale — prospect or customer — with the minimum data set of cahier §15.5 (CRM-01). One record per store whatever the creation path; the login, if any, is linked through userId. Table resellers · written by crm (create/edit/assign/validate); orders, field, invitations update the caches and lifecycle through outbox events.

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK. Client-generated UUIDv7 accepted (offline creation); the server upserts idempotently.
numberString @db.VarChar(12)——Unique. REV-000123 from NumberSequence RESELLER (not yearly); assigned when the server first receives the record (offline prospects get it at sync).
storeNameString——Magasin (≤ 120 chars).
ownerNameStringyes—Responsable. Required by the BO and DA create forms; may be missing on imports.
phoneString @db.VarChar(16)——Unique. Main phone, E.164 after normalisation — the duplicate key of CRM-03 on every creation path (checkPhone endpoints).
whatsappString @db.VarChar(16)yes—E.164; often equal to phone.
emailString @db.VarChar(254)yes—Store e-mail (lower-cased); not a login.
wilayaCodeString @db.VarChar(2)——FK → Wilaya.code · Restrict.
communeIdString @db.Uuid——FK → Commune · Restrict.
addressStringyes—Street address of the store.
landmarkStringyes—Point de repère for drivers and delegates.
latDecimal @db.Decimal(9,6)yes—Store GPS (DA-14, BO map). Set together with lng.
lngDecimal @db.Decimal(9,6)yes—WGS 84 longitude, degrees.
gpsAccuracyMetersIntyes—Accuracy reported by the device when the position was captured.
gpsCapturedAtDateTimeyes—Device time of the capture.
locationSourceLocationSourceyes—Who set the position (DA-09 locationSource, SRV-42): FIELD_SIGNUP (a delegate, at creation or on a later edit), BACK_OFFICE, IMPORT, CHECK_IN_VALIDATED (a check-in position the back-office adopted — no operation writes it yet); null without a position or when the reseller placed it. Backfilled from source.
businessTypeCodeString @db.VarChar(40)yes—ReferenceItem BUSINESS_TYPE code (type de commerce).
potentialPotentialyes——
customerTypeCustomerTypeyes—Pricing tier selecting priceSuperGros, priceDemiGros or priceDetail (PRC-02). Declared at sign-up (required there), reviewed at account validation, afterwards changed only by the back-office (crm.update, AuditLog UPDATE with before/after — AUTH-11). May be null for a prospect; required for validationStatus = APPROVED (CHECK).
brandsSoldString[]—[]Brands the store already sells, free tags normalised to upper case (CRM-06 segmentation, competitors included).
statusResellerStatus—NEW_PROSPECTCommercial lifecycle (CRM-04).
statusChangedAtDateTime—now()Time of the last status change (sorting, days in status).
sourceResellerSource——Set once at creation (CRM-02).
validationStatusValidationStatus—PENDINGBack-office validation of the point of sale / account (CRM-09, FLD-14). Records created in the BO or by import start APPROVED.
validatedAtDateTimeyes——
validatedByIdString @db.Uuidyes—FK → User · Restrict.
validationNoteStringyes—Mandatory reason when REJECTED; shown on RA-06.
assignedDelegateIdString @db.Uuidyes—FK → DelegateProfile · Restrict. Delegate in charge (CRM-05). Defaults to the creating delegate for field sign-ups.
zoneIdString @db.Uuidyes—FK → Zone · Restrict. Pre-filled by zone resolution from the commune, editable.
supportRegionIdString @db.Uuidyes—FK → SupportRegion · Restrict. Derived and stored: the region whose SupportRegionWilaya holds wilayaCode. Rewritten in the same transaction when wilayaCode changes and by a bulk update when a region's wilayas are edited; never set by hand. null → default region at read time (SUP-05).
createdByUserIdString @db.Uuid——FK → User · Restrict. Creating user: delegate (field), staff (BO/import) or the reseller (self sign-up). Never changes.
userIdString @db.Uuidyes—Unique. FK → User · Restrict. Reseller login once the account exists or is claimed (AUTH-05). Unique: one account per point of sale in V1.
legalNameStringyes—Raison sociale printed on invoices (defaults to storeName).
nifString @db.VarChar(20)yes—Tax id (NIF), invoice buyer mention.
nisString @db.VarChar(20)yes—Statistical id (NIS).
rcNumberString @db.VarChar(30)yes—Commerce register number (RC).
aiNumberString @db.VarChar(20)yes—Tax article number (AI).
lastOrderAtDateTimeyes—Cache: submission time of the latest non-cancelled order.
lastVisitAtDateTimeyes—Cache: end time of the latest DONE visit.
archivedAtDateTimeyes—Hidden from lists (test records, historical duplicates before the V2 merge tool); never deleted.
versionInt—1Optimistic lock: every write does version = version + 1 WHERE version = :expected; 0 rows → 409 VERSION_CONFLICT.
createdAtDateTime—now()—
updatedAtDateTime—@updatedAt—
  • Relations: wilaya → Wilaya (wilayaCode, onDelete: Restrict) · commune → Commune (communeId, onDelete: Restrict) · validatedBy → User (validatedById, onDelete: Restrict) · assignedDelegate → DelegateProfile (assignedDelegateId, onDelete: Restrict) · zone → Zone (zoneId, onDelete: Restrict) · supportRegion → SupportRegion (supportRegionId, onDelete: Restrict) · createdByUser → User (createdByUserId, onDelete: Restrict) · user → User (userId, onDelete: Restrict).
  • Referenced by: ResellerAddress.resellerId, ResellerDocument.resellerId, ResellerPhoto.resellerId, ResellerStatusChange.resellerId, ResellerAssignment.resellerId, Interaction.resellerId, CrmTask.resellerId, ResellerSegment.resellerId, ProductVisibilityRule.resellerId, Cart.resellerId, Order.resellerId, ReturnRequest.resellerId, Invoice.resellerId, Payment.resellerId, CreditAccount.resellerId, CreditNote.resellerId, PaymentReminder.resellerId, PromotionAudience.resellerId and 5 more.
  • Keys & indexes: number unique · phone unique · userId unique · @@index([assignedDelegateId, status]) · @@index([zoneId, status]) · @@index([wilayaCode, communeId]) · @@index([status, statusChangedAt]) · @@index([validationStatus, createdAt]) · @@index([assignedDelegateId, updatedAt]) · @@index([lastOrderAt]) · @@index([customerType]) · @@index([supportRegionId]).
  • Enums: Potential, CustomerType, ResellerStatus, ResellerSource, ValidationStatus.
  • Lifecycle: Ten statuses of ResellerStatus; automatic transitions come from events (invitation.app_installed → APP_INSTALLED, account.created + phone verified → ACCOUNT_CREATED, first order submitted → FIRST_ORDER, first order delivered → ACTIVE, visit report next action or inactivity → TO_FOLLOW_UP); manual ones through admin.resellers.setStatus and delegate.resellers.update.
  • History: Every status change → ResellerStatusChange; every delegate/zone change → ResellerAssignment; field edits → AuditLog UPDATE with the diff (AUD-02).
  • Derived, not stored: activity (actif / inactif) — lastOrderAt compared with the setting crm.inactiveAfterDays; loyalty level — LoyaltyAccount (V2); balance and available credit — billing queries; open-task and visit counters — counts on CrmTask/Visit.
  • Why lastOrderAt / lastVisitAt are stored: list sorting and the inactive resellers filter over thousands of rows must not aggregate orders and visits on every page load. They are refreshed in the same transaction as the order submission / visit end, never edited by hand.
  • Delegates edit offline: delegate.resellers.update sends the version it last saw; a mismatch is a sync CONFLICT.
  • V2 (PRC-03) adds a nullable priceListId → PriceList for a per-reseller agreement, resolved before the customer-type price (expand migration). There is no price-list column in V1.

Constraints and indexes Prisma cannot express (raw SQL in the migration):

ALTER TABLE resellers ADD CONSTRAINT resellers_gps_pair
  CHECK ((lat IS NULL) = (lng IS NULL));
ALTER TABLE resellers ADD CONSTRAINT resellers_type_when_approved
  CHECK (validation_status <> 'APPROVED' OR customer_type IS NOT NULL);
-- CRM search (BO list, delegate list, global search)
CREATE INDEX resellers_store_name_trgm ON resellers USING gin (f_unaccent(store_name) gin_trgm_ops);
CREATE INDEX resellers_owner_name_trgm ON resellers USING gin (f_unaccent(owner_name) gin_trgm_ops);
-- validation queue (BO-13)
CREATE INDEX resellers_validation_queue ON resellers (created_at) WHERE validation_status = 'PENDING';

Example row:

{
  "id": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "number": "REV-000123",
  "storeName": "Boutique Amine Mobile",
  "ownerName": "Amine Benali",
  "phone": "+213555123456",
  "whatsapp": "+213555123456",
  "email": "amine.mobile@gmail.com",
  "wilayaCode": "16",
  "communeId": "01a15db0-5680-7730-9115-ea5cf224e9ae",
  "address": "12 rue Didouche Mourad",
  "landmark": "En face de la poste centrale",
  "lat": 36.773105,
  "lng": 3.058821,
  "gpsAccuracyMeters": 12,
  "gpsCapturedAt": "2026-11-02T09:38:12.000Z",
  "businessTypeCode": "PHONE_SHOP",
  "potential": "HIGH",
  "customerType": "DEMI_GROS",
  "brandsSold": [
    "HOCO",
    "BASEUS",
    "OTHER"
  ],
  "status": "ACTIVE",
  "statusChangedAt": "2026-11-08T11:45:00.000Z",
  "source": "DELEGATE_APP",
  "validationStatus": "APPROVED",
  "validatedAt": "2026-11-03T10:40:12.000Z",
  "validatedById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
  "validationNote": null,
  "assignedDelegateId": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "zoneId": "01a17280-b500-7f65-a38a-52982a7faa5e",
  "supportRegionId": "01a17c96-7e80-7bd8-b8aa-2b5b2d991956",
  "createdByUserId": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "userId": "01a1a077-1080-7aed-87e0-11ae957ae11e",
  "legalName": "BENALI AMINE",
  "nif": "198716010123456",
  "nis": "198716010654321",
  "rcNumber": "16/00-1234567A19",
  "aiNumber": "16012345678",
  "lastOrderAt": "2026-11-05T15:20:00.000Z",
  "lastVisitAt": "2026-11-05T09:31:00.000Z",
  "archivedAt": null,
  "version": 3,
  "createdAt": "2026-11-02T09:41:00.000Z",
  "updatedAt": "2026-11-08T11:45:00.000Z"
}

ResellerAddress

A delivery address of a reseller (RA-19). The store address is copied as the first, default address when the account is created. Table reseller_addresses · written by crm (addresses.*).

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK.
resellerIdString @db.Uuid——FK → Reseller · Cascade.
labelString @db.VarChar(40)yes——
recipientNameString———
phoneString @db.VarChar(16)——E.164 contact for the driver.
wilayaCodeString @db.VarChar(2)——FK → Wilaya.code · Restrict.
communeIdString @db.Uuid——FK → Commune · Restrict.
addressLineString———
landmarkStringyes——
latDecimal @db.Decimal(9,6)yes—WGS 84 latitude, degrees.
lngDecimal @db.Decimal(9,6)yes—WGS 84 longitude, degrees.
isDefaultBoolean—false—
archivedAtDateTimeyes—addresses.remove archives (orders keep their snapshot); the last active address cannot be removed.
createdAtDateTime—now()—
updatedAtDateTime—@updatedAt—
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · wilaya → Wilaya (wilayaCode, onDelete: Restrict) · commune → Commune (communeId, onDelete: Restrict).
  • Referenced by: Order.deliveryAddressId.
  • Keys & indexes: @@index([resellerId]).

Constraints and indexes Prisma cannot express (raw SQL in the migration):

CREATE UNIQUE INDEX reseller_addresses_one_default ON reseller_addresses (reseller_id)
  WHERE is_default AND archived_at IS NULL;

Example row:

{
  "id": "01a1a07e-6380-7674-b5bd-e46066ccc2c4",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "label": "Magasin",
  "recipientName": "Amine Benali",
  "phone": "+213555123456",
  "wilayaCode": "16",
  "communeId": "01a15db0-5680-7730-9115-ea5cf224e9ae",
  "addressLine": "12 rue Didouche Mourad",
  "landmark": "En face de la poste centrale",
  "lat": 36.773105,
  "lng": 3.058821,
  "isDefault": true,
  "archivedAt": null,
  "createdAt": "2026-11-03T08:20:00.000Z",
  "updatedAt": "2026-11-03T08:20:00.000Z"
}

ResellerDocument

A store document — RC, NIF, NIS, AI, ID card, contract (CRM-10). The file itself is a private File. Table reseller_documents · written by crm (profile.documents.add, admin.resellers.documents.add).

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK.
resellerIdString @db.Uuid——FK → Reseller · Cascade.
typeDocumentType———
fileIdString @db.Uuid——Unique. FK → File · Restrict. Purpose STORE_DOCUMENT.
documentNumberString @db.VarChar(40)yes—Number printed on the document, copied to the Reseller legal fields when approved.
expiresOnDateTime @db.Dateyes——
statusValidationStatus—PENDINGDocument check during account validation.
reviewedByIdString @db.Uuidyes—FK → User · Restrict.
reviewedAtDateTimeyes——
rejectionReasonStringyes——
uploadedByIdString @db.Uuid——FK → User · Restrict.
archivedAtDateTimeyes—Set when replaced by a newer document of the same type.
createdAtDateTime—now()—
updatedAtDateTime—@updatedAt—
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · file → File (fileId, onDelete: Restrict) · reviewedBy → User (reviewedById, onDelete: Restrict) · uploadedBy → User (uploadedById, onDelete: Restrict).
  • Keys & indexes: fileId unique · @@index([resellerId, type]).
  • Enums: DocumentType, ValidationStatus.
  • Readable only by crm.view holders, the reseller (self) and the assigned delegate, through 5-minute signed URLs (SEC-04).

Example row:

{
  "id": "01a1a082-f760-74d0-95fa-dc619b3d542c",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "type": "COMMERCE_REGISTER",
  "fileId": "01a19b59-52dc-7b4c-a5c1-8d79b37fb93f",
  "documentNumber": "16/00-1234567A19",
  "expiresOn": null,
  "status": "APPROVED",
  "reviewedById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
  "reviewedAt": "2026-11-03T10:38:00.000Z",
  "rejectionReason": null,
  "uploadedById": "01a1a077-1080-7aed-87e0-11ae957ae11e",
  "archivedAt": null,
  "createdAt": "2026-11-03T08:25:00.000Z",
  "updatedAt": "2026-11-03T10:38:00.000Z"
}

ResellerPhoto

A store photo — façade, interior, shelf (CRM-10, DA-14). Table reseller_photos · written by crm (delegate.resellers.addPhoto, BO); field copies façade photos taken during visits.

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK. Client-generated UUIDv7 accepted (offline creation); the server upserts idempotently.
resellerIdString @db.Uuid——FK → Reseller · Cascade.
fileIdString @db.Uuid——Unique. FK → File · Restrict. Purpose STORE_PHOTO.
kindPhotoKind———
isPrimaryBoolean—falseThe façade shown on cards and the point-of-sale sheet.
occurredAtDateTimeyes—Device capture time.
captionString @db.VarChar(140)yes—Caption typed by the delegate (delegate.resellers.addPhoto).
latDecimal @db.Decimal(9,6)yes—WGS 84 latitude, degrees.
lngDecimal @db.Decimal(9,6)yes—WGS 84 longitude, degrees.
takenByIdString @db.Uuid——FK → User · Restrict.
visitIdString @db.Uuidyes—FK → Visit · SetNull. Set when the photo was taken during a visit.
archivedAtDateTimeyes——
createdAtDateTime—now()Server receipt time.
updatedAtDateTime—@updatedAt—
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · file → File (fileId, onDelete: Restrict) · takenBy → User (takenById, onDelete: Restrict) · visit → Visit (visitId, onDelete: SetNull).
  • Keys & indexes: fileId unique · @@index([resellerId, kind]).
  • Enums: PhotoKind.

Constraints and indexes Prisma cannot express (raw SQL in the migration):

CREATE UNIQUE INDEX reseller_photos_one_primary ON reseller_photos (reseller_id)
  WHERE is_primary AND archived_at IS NULL;

Example row:

{
  "id": "01a19ba0-5b20-7d9d-a775-6adf1010bb8d",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "fileId": "01a19b4c-f0ce-792f-b254-d23fb48067c2",
  "kind": "FACADE",
  "isPrimary": true,
  "occurredAt": "2026-11-02T09:38:40.000Z",
  "lat": 36.77311,
  "lng": 3.058815,
  "takenById": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "visitId": null,
  "archivedAt": null,
  "createdAt": "2026-11-02T09:39:00.000Z",
  "updatedAt": "2026-11-02T09:39:00.000Z"
}

Lifecycle and assignment history

ResellerStatusChange

Append-only history of Reseller.status (CRM-04, AUD-02); feeds the conversion funnel. Table reseller_status_changes · written by crm (only writer; automatic changes come from outbox consumers).

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK.
resellerIdString @db.Uuid——FK → Reseller · Cascade.
fromStatusResellerStatusyes—null for the creation row.
toStatusResellerStatus———
triggerStatusChangeTrigger—MANUAL—
eventKeyString @db.VarChar(64)yes—Outbox event or job that caused an AUTOMATIC change.
reasonStringyes—Required for manual changes to SUSPENDED or NOT_INTERESTED.
actorUserIdString @db.Uuidyes—FK → User · Restrict. null for automatic changes.
occurredAtDateTime——When the change happened (device time for offline delegate edits).
createdAtDateTime—now()Server receipt time.
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · actorUser → User (actorUserId, onDelete: Restrict).
  • Keys & indexes: @@index([resellerId, createdAt]) · @@index([toStatus, occurredAt]).
  • Enums: ResellerStatus, StatusChangeTrigger (proposed).

Example row:

{
  "id": "01a1baf9-e630-725c-ae49-d809d457dc79",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "fromStatus": "FIRST_ORDER",
  "toStatus": "ACTIVE",
  "trigger": "AUTOMATIC",
  "eventKey": "order.delivered",
  "reason": null,
  "actorUserId": null,
  "occurredAt": "2026-11-08T11:45:00.000Z",
  "createdAt": "2026-11-08T11:45:02.000Z"
}

ResellerAssignment

Append-only log of delegate and zone (re)assignments (CRM-05). Table reseller_assignments · written by crm (admin.resellers.assign, bulk, creation).

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK.
resellerIdString @db.Uuid——FK → Reseller · Cascade.
fromDelegateIdString @db.Uuidyes—FK → DelegateProfile · Restrict.
toDelegateIdString @db.Uuidyes—FK → DelegateProfile · Restrict.
fromZoneIdString @db.Uuidyes—FK → Zone · Restrict.
toZoneIdString @db.Uuidyes—FK → Zone · Restrict.
reasonStringyes——
assignedByIdString @db.Uuidyes—FK → User · Restrict. null = automatic (creation, zone resolution).
createdAtDateTime—now()—
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · fromDelegate → DelegateProfile (fromDelegateId, onDelete: Restrict) · toDelegate → DelegateProfile (toDelegateId, onDelete: Restrict) · fromZone → Zone (fromZoneId, onDelete: Restrict) · toZone → Zone (toZoneId, onDelete: Restrict) · assignedBy → User (assignedById, onDelete: Restrict).
  • Keys & indexes: @@index([resellerId, createdAt]) · @@index([fromDelegateId, createdAt]) · @@index([toDelegateId, createdAt]).
  • sync.delta uses fromDelegateId to tell a delegate app which resellers left its scope since its cursor (rows are never deleted, so there is no tombstone table).

Example row:

{
  "id": "01a19ba2-2fe0-7239-a7df-d1e8b9ca3d73",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "fromDelegateId": null,
  "toDelegateId": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "fromZoneId": null,
  "toZoneId": "01a17280-b500-7f65-a38a-52982a7faa5e",
  "reason": "Création terrain",
  "assignedById": null,
  "createdAt": "2026-11-02T09:41:00.000Z"
}

Timeline and follow-ups

Interaction

One entry of the unified reseller timeline (CRM-07): calls and notes typed by people, plus automatic entries mirroring business events. Table interactions · written by crm (manual entries); outbox consumers write the automatic entries for orders, payments, visits, claims and status changes.

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK. Client-generated UUIDv7 accepted (offline creation); the server upserts idempotently.
resellerIdString @db.Uuid——FK → Reseller · Cascade.
typeInteractionType———
isAutomaticBoolean—false—
actorUserIdString @db.Uuidyes—FK → User · Restrict.
occurredAtDateTime——When it happened (device time for offline notes).
summaryString——One line shown in the timeline (≤ 200 chars).
bodyStringyes—Long text of a note.
durationSecondsIntyes—Calls only.
refTypeString @db.VarChar(32)yes—Source entity of an automatic entry: ORDER, PAYMENT, VISIT, TICKET, RESELLER_STATUS_CHANGE.
refIdString @db.Uuidyes—Id of that entity (no FK: polymorphic).
createdAtDateTime—now()Server receipt time.
updatedAtDateTime—@updatedAt—
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · actorUser → User (actorUserId, onDelete: Restrict).
  • Keys & indexes: @@index([resellerId, occurredAt(sort: Desc)]) · @@index([refType, refId]).
  • Enums: InteractionType.
  • Automatic entries are immutable; manual ones can be corrected by their author for 24 h (AuditLog keeps the diff).

Constraints and indexes Prisma cannot express (raw SQL in the migration):

-- an outbox replay never duplicates an automatic entry
CREATE UNIQUE INDEX interactions_automatic_once ON interactions (ref_type, ref_id, type)
  WHERE is_automatic;

Example row:

{
  "id": "01a1ab31-a580-7dc0-8e27-fb68f19edf95",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "type": "CALL",
  "isAutomatic": false,
  "actorUserId": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "occurredAt": "2026-11-05T10:10:00.000Z",
  "summary": "Appel : intéressé par les câbles X88, rappeler lundi",
  "body": null,
  "durationSeconds": 240,
  "refType": null,
  "refId": null,
  "createdAt": "2026-11-05T10:12:00.000Z",
  "updatedAt": "2026-11-05T10:12:00.000Z"
}

CrmTask

A follow-up (relance) or task about a reseller, with a due date and an assignee (CRM-08). Table crm_tasks · written by crm (admin.tasks.*, delegate.tasks.*); field creates the task of a visit report.

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK.
resellerIdString @db.Uuid——FK → Reseller · Restrict.
typeTaskType———
statusTaskStatus—OPEN—
titleString———
descriptionStringyes——
dueAtDateTime——Business-day reminders use Africa/Algiers.
assigneeIdString @db.Uuid——FK → User · Restrict.
createdByIdString @db.Uuid——FK → User · Restrict.
visitReportIdString @db.Uuidyes—Unique. FK → VisitReport · Restrict. Report that created the task; unique, so saving the report again updates the same task.
completedAtDateTimeyes——
completedByIdString @db.Uuidyes—FK → User · Restrict.
outcomeStringyes—Required by delegate.tasks.complete.
cancelledAtDateTimeyes——
remindedAtDateTimeyes—Due-date reminder sent (once).
versionInt—1Optimistic lock of the delegate app's edits (delegate.tasks.update, delegate.tasks.complete); incremented by every edit.
createdAtDateTime—now()—
updatedAtDateTime—@updatedAt—
  • Relations: reseller → Reseller (resellerId, onDelete: Restrict) · assignee → User (assigneeId, onDelete: Restrict) · createdBy → User (createdById, onDelete: Restrict) · visitReport → VisitReport (visitReportId, onDelete: Restrict) · completedBy → User (completedById, onDelete: Restrict).
  • Keys & indexes: visitReportId unique · @@index([assigneeId, status, dueAt]) · @@index([resellerId, status]) · @@index([status, dueAt]).
  • Enums: TaskType, TaskStatus.
  • Derived, not stored: En retard (overdue) — status = OPEN AND dueAt < now() (enums.json note).
  • NextAction → TaskType mapping used by visit reports: PHONE_FOLLOW_UP → CALL, NEW_VISIT → VISIT, SEND_CATALOG → SEND_CATALOG, WHATSAPP_MESSAGE → WHATSAPP, NONE → no task (an existing open task is cancelled).

Example row:

{
  "id": "01a1ab0c-9150-7d3e-b7a1-2cec0a88ca49",
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "type": "CALL",
  "status": "OPEN",
  "title": "Relance téléphonique — commande de câbles",
  "description": "Le gérant attend la promo de la semaine prochaine.",
  "dueAt": "2026-11-09T08:00:00.000Z",
  "assigneeId": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "createdById": "01a1a024-7d34-7424-adb6-099ecaff5095",
  "visitReportId": "01a1ab0b-31c0-7562-b848-5d61818ed475",
  "completedAt": null,
  "completedById": null,
  "outcome": null,
  "cancelledAt": null,
  "remindedAt": null,
  "version": 1,
  "createdAt": "2026-11-05T09:31:30.000Z",
  "updatedAt": "2026-11-05T09:31:30.000Z"
}

Segments

Segment

A named group of resellers used by price lists, visibility rules, promotions, banners and announcements (CRM-06). Table segments · written by crm (admin.segments.crud, BO-15).

FieldPrisma typeNullDefaultConstraints / notes
idString @db.Uuid—uuid(7)PK.
codeString @db.VarChar(30)——Unique.
nameString———
descriptionStringyes——
colorString @db.VarChar(7)yes——
archivedAtDateTimeyes—Archiving is refused while a live price list or promotion targets the segment.
createdByIdString @db.Uuidyes—FK → User · SetNull.
createdAtDateTime—now()—
updatedAtDateTime—@updatedAt—
  • Relations: createdBy → User (createdById, onDelete: SetNull).
  • Referenced by: ResellerSegment.segmentId, ProductVisibilityRule.segmentId, PromotionAudience.segmentId.
  • Keys & indexes: code unique.
  • V1 segments are static (membership by hand or bulk action). Filters on potential, business type, brands sold, level and activity are direct column filters, not segments.

Example row:

{
  "id": "01a17d04-5b80-70bd-a1bf-ec39cbd697ce",
  "code": "GROSSISTES",
  "name": "Grossistes et semi-grossistes",
  "description": "Points de vente revendant à d'autres boutiques.",
  "color": "#ea580c",
  "archivedAt": null,
  "createdById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
  "createdAt": "2026-10-27T11:00:00.000Z",
  "updatedAt": "2026-10-27T11:00:00.000Z"
}

ResellerSegment

Membership of a reseller in a segment. Table reseller_segments · written by crm (admin.resellers.bulk, reseller edit).

FieldPrisma typeNullDefaultConstraints / notes
resellerIdString @db.Uuid——PK. FK → Reseller · Cascade. Part of the composite PK.
segmentIdString @db.Uuid——PK. FK → Segment · Cascade. Part of the composite PK.
addedByIdString @db.Uuidyes—FK → User · SetNull.
createdAtDateTime—now()—
  • Relations: reseller → Reseller (resellerId, onDelete: Cascade) · segment → Segment (segmentId, onDelete: Cascade) · addedBy → User (addedById, onDelete: SetNull).
  • Keys & indexes: @@id([resellerId, segmentId]) · @@index([segmentId]).
  • Link row: removal deletes it (AuditLog UPDATE on the reseller).

Example row:

{
  "resellerId": "01a19ba2-2fe0-7300-a148-73d1a4ef38c0",
  "segmentId": "01a17d04-5b80-70bd-a1bf-ec39cbd697ce",
  "addedById": "01a1a00a-4535-7ff7-af24-a0a3bb095b70",
  "createdAt": "2026-11-04T09:00:00.000Z"
}

On this page