Skip to main content
Sintesis delivers notifications across three channels: in-app (realtime via Supabase), email (via Resend), and WhatsApp (via the WhatsApp Business API). All channels flow through the same rule-based engine so delivery logic is defined once and shared.

In-app notifications

Schema

CREATE TABLE notifications (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  tenant_id   UUID REFERENCES tenants(id) ON DELETE CASCADE,
  title       TEXT NOT NULL,
  body        TEXT,
  type        TEXT NOT NULL DEFAULT 'info',
  action_url  TEXT,
  data        JSONB NOT NULL DEFAULT '{}',
  read_at     TIMESTAMPTZ,       -- NULL = unread
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
ColumnDescription
typeSemantic category: info, success, reminder, flujo_email, etc.
action_urlDeep link to the relevant obra or document
dataArbitrary JSON payload — used by the UI to render richer cards
read_atSet when the user marks the notification as read; NULL means unread
pendiente_idOptional link to an obra_pendientes record

Realtime delivery

The notifications table is added to the supabase_realtime publication so every INSERT streams to subscribed clients without polling:
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications;
Clients subscribe to their own user channel:
const channel = supabase
  .channel("notifications")
  .on(
    "postgres_changes",
    {
      event: "INSERT",
      schema: "public",
      table: "notifications",
      filter: `user_id=eq.${userId}`,
    },
    (payload) => {
      // show toast or update badge count
    }
  )
  .subscribe();

Row-level security

Users can only read, insert, update, and delete their own notifications:
CREATE POLICY "read own notifications"
  ON notifications FOR SELECT
  USING (user_id = auth.uid());

CREATE POLICY "insert own notifications"
  ON notifications FOR INSERT
  WITH CHECK (user_id = auth.uid());
Workflow steps that insert notifications on behalf of other users (e.g. flujo actions) call the workflow_insert_notification RPC with service-role credentials:
CREATE FUNCTION workflow_insert_notification(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  INSERT INTO notifications (
    user_id, tenant_id, title, body,
    type, action_url, pendiente_id, data
  ) VALUES (
    (payload->>'user_id')::uuid,
    (payload->>'tenant_id')::uuid,
    payload->>'title',
    payload->>'body',
    payload->>'type',
    payload->>'action_url',
    (payload->>'pendiente_id')::uuid,
    payload->'data'
  );
END;
$$;

The notification engine

All notification delivery — regardless of channel or timing — goes through lib/notifications/engine.ts.

Defining a rule

import { defineRule } from "@/lib/notifications/engine";

defineRule("obra.completed", {
  recipients: async (ctx) => {
    // Return an array of user_ids to notify
    const ids: string[] = [];
    if (ctx.actorId) ids.push(ctx.actorId);
    return ids;
  },
  effects: [
    {
      channel: "in-app",
      when: "now",
      title: (ctx) => `Obra completada`,
      body: (ctx) => `La obra "${ctx.obra?.name}" alcanzó el 100%.`,
      actionUrl: (ctx) => ctx.obra?.id ? `/excel/${ctx.obra.id}` : null,
      type: "success",
    },
    {
      channel: "email",
      when: (ctx) => ctx.followUpAt
        ? new Date(ctx.followUpAt)
        : new Date(Date.now() + 2 * 60 * 1000),
      subject: (ctx) => `Seguimiento: ${ctx.obra?.name}`,
      html: (ctx) => `<p>La obra <strong>${ctx.obra?.name}</strong> fue completada.</p>`,
    },
  ],
});

Effect definition fields

FieldTypeDescription
channel"in-app" | "email"Delivery channel
when"now" | Date | (ctx) => ...Delivery time; past dates are treated as "now"
title(ctx) => stringNotification title (in-app)
body(ctx) => stringNotification body (in-app)
subject(ctx) => stringEmail subject
html(ctx) => stringEmail HTML body
actionUrl(ctx) => stringDeep link (in-app)
typestring | (ctx) => stringNotification type tag
shouldSend(ctx) => booleanOptional guard to skip delivery
data(ctx) => Record<string,any>Arbitrary JSON stored with in-app record

Role-based recipients

Recipient arrays support the roleid:<uuid> prefix to fan out to all users with a given role:
recipients: async (ctx) => [
  ctx.actorId,
  `roleid:${ctx.reviewerRoleId}`,  // expands to all users with this role
],
The engine resolves these by querying user_roles using the admin client before building the effect list.

Emitting an event

import { emitEvent } from "@/lib/notifications/engine";

await emitEvent("obra.completed", {
  tenantId: obra.tenant_id,
  actorId:  user.id,
  obra:     { id: obra.id, name: obra.name },
});
If a Vercel Workflow is available, emitEvent serializes all expanded effects and starts deliverEffectsWorkflow. Each effect’s when time becomes a sleep() call in the workflow. The function returns { runId } so callers can store the ID for cancellation.

Helper functions

For simple one-off notifications without defining a rule:
import { notifyInApp, notifyInAppForRole } from "@/lib/notifications/api";

// Single user
await notifyInApp({
  tenantId: "<uuid>",
  userId:   "<uuid>",
  title:    "Documento aprobado",
  body:     "El contrato fue aprobado por el revisor.",
  type:     "success",
  actionUrl: `/excel/${obraId}`,
});

// All users with a role
await notifyInAppForRole({
  tenantId: "<uuid>",
  roleId:   "<uuid>",
  title:    "Nueva obra asignada",
  type:     "info",
});
These are backed by the custom.in_app and custom.in_app.role rules registered in lib/notifications/api.ts.

Email notifications

Emails are sent via Resend. Two helper functions wrap the Resend API inside Vercel Workflow steps:
// lib/workflow/email.ts

// Generic single email
export async function sendSimpleEmailEdge(payload: {
  to: string;
  subject: string;
  html: string;
})

// Obra completion emails (uses the obra-completion template)
export async function sendObraCompletionEmailEdge(
  options: ObraEmailTemplateInput
)
Both functions use workflowFetch (the workflow-aware fetch primitive) so the HTTP call is a durable workflow step and will retry on transient failures.

Obra completion email template

The emails/obra-completion.tsx template renders a completion summary for one or more obras:
interface ObraCompletionEmailProps {
  recipientName?: string | null;
  obras: { name: string; percentage: number }[];
  introMessage?: string;
}
The default intro message is "Te informamos que las siguientes obras alcanzaron el 100% de avance:".

Required environment variables

VariableDescription
RESEND_API_KEYResend API key
RESEND_FROM_EMAILSender address (e.g. [email protected])

Document reminders

The /api/doc-reminders endpoint creates a scheduled notification for a pending document that fires the day before the due date at 09:00.

Request

POST /api/doc-reminders
Content-Type: application/json

{
  "obraId":       "<uuid>",
  "obraName":     "Construcción de 20 viviendas",
  "documentName": "Certificado de avance mensual",
  "dueDate":      "2026-04-15",
  "notifyUserId": "<uuid>",
  "pendienteId":  "<uuid>"
}
FieldRequiredDescription
obraIdYesThe obra the document belongs to
documentNameYesDisplay name shown in the notification
dueDateYesISO date string (YYYY-MM-DD)
notifyUserIdNoUser to receive the reminder; defaults to the authenticated user
pendienteIdNoLinks the notification to an obra_pendientes record

What happens

1

Immediate confirmation

An in-app notification with type: "reminder" and data.stage: "created" is inserted immediately so the user receives a toast confirming the reminder was scheduled.
2

Scheduled delivery

A document.reminder.requested event is emitted. The engine resolves the when callback to the day before dueDate at 09:00 local time and starts a Vercel Workflow that sleeps until that moment.
3

Reminder fires

At 09:00 the day before the document is due, an in-app notification is delivered with data.stage: "due_1d" and a deep link to the obra.
The reminder rule in lib/notifications/rules.ts:
defineRule("document.reminder.requested", {
  recipients: async (ctx) => {
    return ctx.notifyUserId ? [ctx.notifyUserId] : [];
  },
  effects: [
    {
      channel: "in-app",
      when: (ctx) => {
        const due = parseLocalDate(ctx.dueDate ?? null);
        if (!due) return null;
        const dayBefore = new Date(due.getTime() - 24 * 60 * 60 * 1000);
        dayBefore.setHours(9, 0, 0, 0);
        return dayBefore;
      },
      title: (ctx) => `Recordatorio: ${ctx.documentName} pendiente`,
      body: (ctx) => `Vence el documento de "${ctx.obraName}" el ${ctx.dueDate}.`,
      actionUrl: (ctx) => ctx.obraId ? `/excel/${ctx.obraId}` : null,
      type: "reminder",
    },
  ],
});

Notifications page

The /notifications page (app/notifications/page.tsx) provides a full overview of the authenticated user’s notification activity:

Pending documents calendar

Document reminders are converted to calendar events and displayed on a full-calendar view. Events are colour-coded: overdue in red, due today in amber, upcoming in green, no-date in violet.

Metrics panel

Four at-a-glance metrics: pending count, completed count, overdue count, and upcoming events in the next 7 days, each with a week-over-week delta badge.

Mark as read

Users can mark individual notifications or all notifications as read:
UPDATE notifications
SET read_at = NOW()
WHERE user_id = auth.uid()
AND tenant_id = <tenant_id>
AND read_at IS NULL;

Calendar event types

SourceID prefixEditable?
Flujo actionsflujo-<action-uuid>Yes — updating reschedules the workflow
Document reminders<pendiente-id>No
Manual calendar events<event-uuid>Yes
When a flujo calendar event is updated via the calendar UI, the obra_flujo_actions record is updated with the new scheduled_date and timing_mode: "scheduled". The rescheduling logic in PUT /api/flujo-actions then cancels any running workflow and starts a new one.

WhatsApp integration

Sintesis accepts inbound WhatsApp messages to upload images to obra folders via the WhatsApp Business API (Meta Graph API v22.0).

Webhook endpoints

MethodPathPurpose
GET/api/whatsapp/webhookMeta webhook verification challenge
POST/api/whatsapp/webhookReceive inbound messages

Verification handshake

Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge. The endpoint checks hub.verify_token against WHATSAPP_VERIFY_TOKEN and echoes back the challenge:
if (mode === "subscribe" && token === VERIFY_TOKEN) {
  return new NextResponse(challenge ?? "", { status: 200 });
}

Uploading images via WhatsApp

Users send an image message with a caption describing the target obra and folder:
Photo (image attachment)
Caption: obra CONSTRUCCION DE 20 VIVIENDAS carpeta Planos
The webhook handler:
1

Parse instruction

Extracts obraName (or obraNumber) and folderName from the caption. The keyword obra precedes the obra identifier and carpeta precedes the folder name.
2

Resolve obra

Looks up the obra by name (case-insensitive ILIKE search) or by numeric ID. If multiple obras match the name, the first result (ordered by obra number) is used.
3

Download media

Fetches the media URL from the Meta Graph API using the message’s image.id, then downloads the binary.
4

Upload to Supabase Storage

Uploads the file to the obra-documents bucket at path {obraId}/{folderName}/{timestamp}-{mediaId}.{ext}.
5

Reply to sender

Sends a WhatsApp text reply confirming success, with links to the obra page and a signed URL for the uploaded file (valid 24 hours).

Supported image types

MIME typeExtension
image/jpeg.jpg
image/png.png
image/webp.webp
image/heic.heic
image/heif.heif

Example caption formats

obra NOMBRE DE OBRA carpeta Planos
obra 42 carpeta Fotos
NOMBRE DE OBRA carpeta whatsapp
Text-only WhatsApp messages receive an automated reply prompting the user to send an image with the correct caption format.

Environment variables

VariableDescription
WHATSAPP_VERIFY_TOKENSecret token for Meta webhook verification
WHATSAPP_ACCESS_TOKENMeta access token for sending/receiving messages
WHATSAPP_PHONE_NUMBER_IDThe Meta phone number ID for the WhatsApp Business account

Emitting a notification event from server code

The canonical way to send a notification from any server action or API route:
import { emitEvent } from "@/lib/notifications/engine";
import "@/lib/notifications/rules"; // ensure rules are registered

await emitEvent("obra.completed", {
  tenantId: obra.tenant_id,
  actorId:  user.id,
  obra:     { id: obra.id, name: obra.name },
  followUpAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
});
Or via the HTTP endpoint (used by Supabase triggers and Edge Functions):
POST /api/notifications/emit
Content-Type: application/json
X-Signature: <hmac-sha256-signature>  # required unless REQUEST_SIGNING_DISABLED=1

{
  "type": "obra.completed",
  "ctx": {
    "tenantId": "<uuid>",
    "actorId": "<uuid>",
    "obra": { "id": "<uuid>", "name": "Mi Obra" }
  }
}
The /api/notifications/emit endpoint validates an HMAC-SHA256 request signature by default. Set REQUEST_SIGNING_DISABLED=1 only in development environments.

Built-in event types

Event typeTriggerChannels
obra.completedObra reaches 100%in-app (now), email (2 min delay)
document.reminder.requestedUser creates a doc reminderin-app (day before due at 09:00)
flujo.action.triggeredFlujo action firesin-app and/or email (at executeAt)
custom.in_appnotifyInApp() helperin-app (immediate or scheduled)
custom.in_app.rolenotifyInAppForRole() helperin-app for all users with role

Build docs developers (and LLMs) love