fix notifications
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s

This commit is contained in:
root
2026-07-22 22:31:39 -04:00
parent bcabd17220
commit f6fcd7ce54
26 changed files with 1171 additions and 361 deletions
@@ -0,0 +1,105 @@
-- Durable, recipient-safe notification model. The legacy notifications table is
-- intentionally retained for rollback and audit during the migration window.
CREATE TYPE "NotificationRecipientType" AS ENUM ('EMPLOYEE', 'RENTER');
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER');
CREATE TYPE "NotificationOutboxStatus" AS ENUM ('PENDING', 'PUBLISHED', 'FAILED');
CREATE TABLE "notification_events" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"templateKey" TEXT,
"locale" TEXT NOT NULL DEFAULT 'en',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"data" JSONB,
"sourceType" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_events_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_events_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "notification_recipients" (
"id" TEXT NOT NULL,
"notificationEventId" TEXT NOT NULL,
"recipientType" "NotificationRecipientType" NOT NULL,
"employeeId" TEXT,
"renterId" TEXT,
"readAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_recipients_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_recipients_notificationEventId_fkey" FOREIGN KEY ("notificationEventId") REFERENCES "notification_events"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "notification_recipients_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "notification_recipients_renterId_fkey" FOREIGN KEY ("renterId") REFERENCES "renters"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "notification_recipients_exactly_one_identity_chk" CHECK (
(CASE WHEN "employeeId" IS NULL THEN 0 ELSE 1 END) +
(CASE WHEN "renterId" IS NULL THEN 0 ELSE 1 END) = 1
),
CONSTRAINT "notification_recipients_type_identity_chk" CHECK (
("recipientType" = 'EMPLOYEE' AND "employeeId" IS NOT NULL AND "renterId" IS NULL) OR
("recipientType" = 'RENTER' AND "renterId" IS NOT NULL AND "employeeId" IS NULL)
)
);
CREATE TABLE "notification_deliveries" (
"id" TEXT NOT NULL,
"notificationRecipientId" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"lastAttemptAt" TIMESTAMP(3),
"nextAttemptAt" TIMESTAMP(3),
"provider" TEXT,
"providerMessageId" TEXT,
"failureCode" TEXT,
"failureReason" TEXT,
"preferenceDecision" TEXT,
"sentAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_deliveries_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_deliveries_notificationRecipientId_fkey" FOREIGN KEY ("notificationRecipientId") REFERENCES "notification_recipients"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "notification_outbox" (
"id" TEXT NOT NULL,
"notificationEventId" TEXT NOT NULL,
"status" "NotificationOutboxStatus" NOT NULL DEFAULT 'PENDING',
"payload" JSONB NOT NULL,
"publishedAt" TIMESTAMP(3),
"failureReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_outbox_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_outbox_notificationEventId_fkey" FOREIGN KEY ("notificationEventId") REFERENCES "notification_events"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "company_notification_preferences" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"notificationType" "NotificationType" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "company_notification_preferences_pkey" PRIMARY KEY ("id"),
CONSTRAINT "company_notification_preferences_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "notification_events_companyId_idempotencyKey_key" ON "notification_events"("companyId", "idempotencyKey");
CREATE INDEX "notification_events_companyId_type_createdAt_idx" ON "notification_events"("companyId", "type", "createdAt");
CREATE INDEX "notification_recipients_employeeId_readAt_createdAt_idx" ON "notification_recipients"("employeeId", "readAt", "createdAt");
CREATE INDEX "notification_recipients_renterId_readAt_createdAt_idx" ON "notification_recipients"("renterId", "readAt", "createdAt");
CREATE INDEX "notification_recipients_notificationEventId_idx" ON "notification_recipients"("notificationEventId");
CREATE UNIQUE INDEX "notification_deliveries_notificationRecipientId_channel_key" ON "notification_deliveries"("notificationRecipientId", "channel");
CREATE INDEX "notification_deliveries_channel_status_nextAttemptAt_idx" ON "notification_deliveries"("channel", "status", "nextAttemptAt");
CREATE INDEX "notification_outbox_status_createdAt_idx" ON "notification_outbox"("status", "createdAt");
CREATE UNIQUE INDEX "company_notification_preferences_companyId_notificationType_channel_key" ON "company_notification_preferences"("companyId", "notificationType", "channel");
@@ -0,0 +1,94 @@
WITH notification_menu AS (
INSERT INTO "menu_items" (
"id",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
"openInNewTab",
"isRequired",
"isActive",
"createdAt",
"updatedAt",
"createdBy",
"updatedBy"
)
VALUES (
'menu_notifications',
'notifications',
'Notifications',
'INTERNAL_PAGE'::"MenuItemType",
'/notifications',
'Bell',
120,
false,
false,
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
'system',
'system'
)
ON CONFLICT ("systemKey") DO UPDATE
SET
"label" = EXCLUDED."label",
"itemType" = EXCLUDED."itemType",
"routeOrUrl" = EXCLUDED."routeOrUrl",
"icon" = EXCLUDED."icon",
"isActive" = true,
"updatedBy" = 'system',
"updatedAt" = CURRENT_TIMESTAMP
RETURNING "id"
),
notification_plan_assignments ("plan", "displayOrder") AS (
VALUES
('STARTER'::"Plan", 120),
('GROWTH'::"Plan", 120),
('PRO'::"Plan", 120)
)
INSERT INTO "subscription_menu_items" (
"id",
"plan",
"menuItemId",
"displayOrder",
"isActive",
"createdAt",
"updatedAt"
)
SELECT
'menu_sub_' || lower(npa."plan"::text) || '_notifications',
npa."plan",
notification_menu."id",
npa."displayOrder",
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM notification_plan_assignments npa
CROSS JOIN notification_menu
ON CONFLICT ("plan", "menuItemId") DO UPDATE
SET
"displayOrder" = EXCLUDED."displayOrder",
"isActive" = true,
"updatedAt" = CURRENT_TIMESTAMP;
WITH notification_menu AS (
SELECT "id" FROM "menu_items" WHERE "systemKey" = 'notifications'
),
notification_roles ("role") AS (
VALUES
('OWNER'::"EmployeeRole"),
('MANAGER'::"EmployeeRole"),
('AGENT'::"EmployeeRole")
)
INSERT INTO "menu_item_role_visibility" ("id", "menuItemId", "role", "createdAt", "updatedAt")
SELECT
'menu_role_' || lower(notification_roles."role"::text) || '_notifications',
notification_menu."id",
notification_roles."role",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM notification_roles
CROSS JOIN notification_menu
ON CONFLICT ("menuItemId", "role") DO NOTHING;
@@ -0,0 +1,27 @@
WITH notification_feature ("plan", "sortOrder") AS (
VALUES
('STARTER'::"Plan", 50),
('GROWTH'::"Plan", 150),
('PRO'::"Plan", 150)
)
INSERT INTO "plan_features" (
"id",
"plan",
"label",
"sortOrder",
"createdAt",
"updatedAt"
)
SELECT
'plan_feature_' || lower(notification_feature."plan"::text) || '_notification_management',
notification_feature."plan",
'NOTIFICATION_MANAGEMENT',
notification_feature."sortOrder",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM notification_feature
ON CONFLICT ("id") DO UPDATE
SET
"label" = EXCLUDED."label",
"sortOrder" = EXCLUDED."sortOrder",
"updatedAt" = CURRENT_TIMESTAMP;
+118
View File
@@ -488,6 +488,27 @@ enum NotificationStatus {
READ
}
enum NotificationRecipientType {
EMPLOYEE
RENTER
}
enum NotificationDeliveryStatus {
PENDING
QUEUED
SENT
DELIVERED
FAILED
SKIPPED
DEAD_LETTER
}
enum NotificationOutboxStatus {
PENDING
PUBLISHED
FAILED
}
enum CalendarBlockType {
MANUAL
MAINTENANCE
@@ -523,6 +544,8 @@ model Company {
insurancePolicies InsurancePolicy[]
pricingRules PricingRule[]
notifications Notification[] @relation("CompanyNotifications")
notificationEvents NotificationEvent[]
notificationDefaults CompanyNotificationPreference[]
complaints Complaint[]
companyMenuItems CompanyMenuItem[]
apiKeys CompanyApiKey[]
@@ -1013,6 +1036,7 @@ model Employee {
isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications")
notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[]
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
@@ -1208,6 +1232,7 @@ model Renter {
savedCompanies RenterSavedCompany[]
reviews Review[]
notifications Notification[] @relation("RenterNotifications")
notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[]
createdAt DateTime @default(now())
@@ -1509,6 +1534,87 @@ model Notification {
@@map("notifications")
}
model NotificationEvent {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
type NotificationType
templateKey String?
locale String @default("en")
title String
body String
data Json?
sourceType String
sourceId String
idempotencyKey String
recipients NotificationRecipient[]
outboxEntries NotificationOutbox[]
createdAt DateTime @default(now())
@@unique([companyId, idempotencyKey])
@@index([companyId, type, createdAt])
@@map("notification_events")
}
model NotificationRecipient {
id String @id @default(cuid())
notificationEventId String
notificationEvent NotificationEvent @relation(fields: [notificationEventId], references: [id], onDelete: Cascade)
recipientType NotificationRecipientType
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade)
renterId String?
renter Renter? @relation(fields: [renterId], references: [id], onDelete: Cascade)
readAt DateTime?
archivedAt DateTime?
deliveries NotificationDelivery[]
createdAt DateTime @default(now())
@@index([employeeId, readAt, createdAt])
@@index([renterId, readAt, createdAt])
@@index([notificationEventId])
@@map("notification_recipients")
}
model NotificationDelivery {
id String @id @default(cuid())
notificationRecipientId String
notificationRecipient NotificationRecipient @relation(fields: [notificationRecipientId], references: [id], onDelete: Cascade)
channel NotificationChannel
status NotificationDeliveryStatus @default(PENDING)
attemptCount Int @default(0)
lastAttemptAt DateTime?
nextAttemptAt DateTime?
provider String?
providerMessageId String?
failureCode String?
failureReason String?
preferenceDecision String?
sentAt DateTime?
deliveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([notificationRecipientId, channel])
@@index([channel, status, nextAttemptAt])
@@map("notification_deliveries")
}
model NotificationOutbox {
id String @id @default(cuid())
notificationEventId String
notificationEvent NotificationEvent @relation(fields: [notificationEventId], references: [id], onDelete: Cascade)
status NotificationOutboxStatus @default(PENDING)
payload Json
publishedAt DateTime?
failureReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@map("notification_outbox")
}
model NotificationTemplate {
id String @id @default(cuid())
templateKey String
@@ -1544,6 +1650,18 @@ model NotificationPreference {
@@map("notification_preferences")
}
model CompanyNotificationPreference {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
notificationType NotificationType
channel NotificationChannel
enabled Boolean @default(true)
@@unique([companyId, notificationType, channel])
@@map("company_notification_preferences")
}
// ═══════════════════════════════════════════════════════════════
// MAINTENANCE
// ═══════════════════════════════════════════════════════════════