Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"git.ignoreLimitWarning": true
"git.ignoreLimitWarning": true,
"codeQL.githubDatabase.update": "never"
}
3 changes: 3 additions & 0 deletions apps/api/src/db/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export type OrgScopedTable = Extract<
| "org_daily_metrics"
| "org_dashboard_snapshot"
| "session_summaries"
| "webhook_deliveries"
| "webhook_events"
| "webhooks"
>;

export function orgTable(
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/modules/employees/employees.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "./employees.schema.js";
import { ok, fail, paginated, handleError } from "../../utils/response.js";
import { NotFoundError } from "../../utils/errors.js";
import { emitEvent } from "../../utils/event-bus.js";

export const employeesController = {
/**
Expand All @@ -29,6 +30,16 @@ export const employeesController = {
"Employee created",
);

emitEvent("employee.created", {
organization_id: request.organizationId,
data: {
employee_id: employee.id,
employee_code: employee.employee_code,
name: employee.name,
created_at: employee.created_at,
},
});

reply.status(201).send(ok(employee));
} catch (error) {
handleError(error, request, reply, "Unexpected error creating employee");
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/modules/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* webhooks.controller.ts β€” HTTP handler layer for the webhooks admin API.
*
* Delegates all business logic to webhooksService.
* Uses the standard ok / paginated / fail / handleError response helpers.
*/

import type { FastifyRequest, FastifyReply } from "fastify";
import { webhooksService } from "./webhooks.service.js";
import {
createWebhookBodySchema,
updateWebhookBodySchema,
deliveryListQuerySchema,
} from "./webhooks.schema.js";
import { ok, paginated, handleError } from "../../utils/response.js";

export const webhooksController = {
// ─── Webhook CRUD ──────────────────────────────────────────────────────────

async create(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const body = createWebhookBodySchema.parse(request.body);
const webhook = await webhooksService.createWebhook(request, body);
reply.status(201).send(ok(webhook));
} catch (error) {
handleError(error, request, reply, "Failed to create webhook");
}
},

async list(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const webhooks = await webhooksService.listWebhooks(request);
reply.status(200).send(ok(webhooks));
} catch (error) {
handleError(error, request, reply, "Failed to list webhooks");
}
},

async update(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
): Promise<void> {
try {
const { id } = request.params;
const body = updateWebhookBodySchema.parse(request.body);
const webhook = await webhooksService.updateWebhook(request, id, body);
reply.status(200).send(ok(webhook));
} catch (error) {
handleError(error, request, reply, "Failed to update webhook");
}
},

async remove(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
): Promise<void> {
try {
const { id } = request.params;
await webhooksService.deleteWebhook(request, id);
reply.status(204).send();
} catch (error) {
handleError(error, request, reply, "Failed to delete webhook");
}
},

// ─── Deliveries ────────────────────────────────────────────────────────────

async listDeliveries(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const query = deliveryListQuerySchema.parse(request.query);
const { data, total } = await webhooksService.listDeliveries(request, query);
reply.status(200).send(paginated(data, query.page, query.limit, total));
} catch (error) {
handleError(error, request, reply, "Failed to list webhook deliveries");
}
},

async retryDelivery(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
): Promise<void> {
try {
const { id } = request.params;
const delivery = await webhooksService.retryDelivery(request, id);
reply.status(200).send(ok(delivery));
} catch (error) {
handleError(error, request, reply, "Failed to retry delivery");
}
},
};
181 changes: 181 additions & 0 deletions apps/api/src/modules/webhooks/webhooks.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* webhooks.repository.ts β€” Data access layer for webhooks, webhook_events,
* and webhook_deliveries tables.
*
* All queries are org-scoped via orgTable() to enforce tenant isolation.
* INSERT and UPSERT operations set organization_id explicitly and call
* supabaseServiceClient directly (matching existing repository conventions).
*/

import type { FastifyRequest } from "fastify";
import { orgTable } from "../../db/query.js";
import { supabaseServiceClient as supabase } from "../../config/supabase.js";
import type {
CreateWebhookBody,
UpdateWebhookBody,
WebhookPublic,
WebhookDelivery,
DeliveryListQuery,
} from "./webhooks.schema.js";

// ─── Webhook CRUD ─────────────────────────────────────────────────────────────

export const webhooksRepository = {
/**
* Create a new webhook for the request's organization.
* The secret is stored but never returned in listing/get responses.
*/
async create(
request: FastifyRequest,
body: CreateWebhookBody,
): Promise<WebhookPublic> {
const { data, error } = await supabase
.from("webhooks")
.insert({
organization_id: request.organizationId,
url: body.url,
secret: body.secret,
events: body.events as string[],
is_active: true,
})
.select("id, organization_id, url, is_active, events, created_at, updated_at")
.single();

if (error) throw new Error(`Failed to create webhook: ${error.message}`);
return data as WebhookPublic;
},

/** Return all webhooks for the request's organization (secret excluded). */
async list(request: FastifyRequest): Promise<WebhookPublic[]> {
const { data, error } = await orgTable(request, "webhooks")
.select("id, organization_id, url, is_active, events, created_at, updated_at")
.order("created_at", { ascending: false });

if (error) throw new Error(`Failed to list webhooks: ${error.message}`);
return (data ?? []) as WebhookPublic[];
},

/** Fetch a single webhook by id, org-scoped. Returns null if not found. */
async findById(
request: FastifyRequest,
webhookId: string,
): Promise<WebhookPublic | null> {
const { data, error } = await orgTable(request, "webhooks")
.select("id, organization_id, url, is_active, events, created_at, updated_at")
.eq("id", webhookId)
.limit(1)
.maybeSingle();

if (error) throw new Error(`Failed to fetch webhook: ${error.message}`);
return (data as WebhookPublic | null) ?? null;
},

/** Update a webhook's mutable fields (url, events, is_active, secret). */
async update(
request: FastifyRequest,
webhookId: string,
body: UpdateWebhookBody,
): Promise<WebhookPublic> {
const patch: Record<string, unknown> = {};
if (body.url !== undefined) patch.url = body.url;
if (body.events !== undefined) patch.events = body.events;
if (body.is_active !== undefined) patch.is_active = body.is_active;
if (body.secret !== undefined) patch.secret = body.secret;

const { data, error } = await orgTable(request, "webhooks")
.update(patch)
.eq("id", webhookId)
.select("id, organization_id, url, is_active, events, created_at, updated_at")
.single();

if (error) throw new Error(`Failed to update webhook: ${error.message}`);
return data as WebhookPublic;
},

/** Soft-delete: permanently remove the webhook row (and cascade deliveries). */
async delete(request: FastifyRequest, webhookId: string): Promise<void> {
const { error } = await orgTable(request, "webhooks")
.delete()
.eq("id", webhookId);

if (error) throw new Error(`Failed to delete webhook: ${error.message}`);
},

// ─── Deliveries ─────────────────────────────────────────────────────────────

/** Paginated list of delivery attempts for the request's org. */
async listDeliveries(
request: FastifyRequest,
query: DeliveryListQuery,
): Promise<{ data: WebhookDelivery[]; total: number }> {
const from = (query.page - 1) * query.limit;
const to = from + query.limit - 1;

let q = orgTable(request, "webhook_deliveries")
.select("*", { count: "exact" })
.order("created_at", { ascending: false })
.range(from, to);

// Chainable filters β€” orgTable returns a select builder
if (query.webhook_id) {
q = (q as ReturnType<typeof q.eq>).eq("webhook_id", query.webhook_id);
}
if (query.status) {
q = (q as ReturnType<typeof q.eq>).eq("status", query.status);
}

const { data, error, count } = await q;

if (error) throw new Error(`Failed to list deliveries: ${error.message}`);
return { data: (data ?? []) as WebhookDelivery[], total: count ?? 0 };
},

/** Fetch a single delivery row by id. */
async findDeliveryById(
request: FastifyRequest,
deliveryId: string,
): Promise<WebhookDelivery | null> {
const { data, error } = await orgTable(request, "webhook_deliveries")
.select("*")
.eq("id", deliveryId)
.limit(1)
.maybeSingle();

if (error) throw new Error(`Failed to fetch delivery: ${error.message}`);
return (data as WebhookDelivery | null) ?? null;
},

/**
* Fetch a webhook's url and secret for delivery β€” only the fields
* needed by the retry / queue path.
*/
async findWebhookSecretById(
request: FastifyRequest,
webhookId: string,
): Promise<{ id: string; url: string; secret: string } | null> {
const { data, error } = await orgTable(request, "webhooks")
.select("id, url, secret")
.eq("id", webhookId)
.limit(1)
.maybeSingle();

if (error) throw new Error(`Failed to fetch webhook secret: ${error.message}`);
return (data as { id: string; url: string; secret: string } | null) ?? null;
},

/** Reset a delivery to pending with an updated next_retry_at. */
async resetDeliveryForRetry(
request: FastifyRequest,
deliveryId: string,
nextRetryAt: string,
): Promise<WebhookDelivery> {
const { data, error } = await orgTable(request, "webhook_deliveries")
.update({ status: "pending", next_retry_at: nextRetryAt })
.eq("id", deliveryId)
.select("*")
.single();

if (error) throw new Error(`Failed to reset delivery: ${error.message}`);
return data as WebhookDelivery;
},
};
Loading
Loading