Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

120 lines
5.0 KiB
TypeScript

import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';
import type { ActionContext } from '../../registries/actionRegistry';
import { getActionRegistryV2 } from '../../registries/actionRegistry';
import {
uuidSchema,
actionProvidedKey,
withTenantTransaction,
requirePermission,
writeRunAudit,
throwActionError
} from './shared';
export function registerNotificationActions(): void {
const registry = getActionRegistryV2();
// ---------------------------------------------------------------------------
// A14 — notifications.send_in_app
// ---------------------------------------------------------------------------
registry.register({
id: 'notifications.send_in_app',
version: 1,
inputSchema: z.object({
recipients: z.object({
user_ids: z.array(uuidSchema).optional().describe('User ids'),
role_ids: z.array(uuidSchema).optional().describe('Role ids (users with these roles receive the notification)'),
role_names: z.array(z.string().min(1)).optional().describe('Role names (case-insensitive)')
}).describe('Recipients'),
title: z.string().min(1).describe('Title'),
body: z.string().min(1).describe('Body'),
severity: z.enum(['info', 'success', 'warning', 'error']).default('info'),
link: z.string().optional().describe('Optional deep link'),
dedupe_key: z.string().optional().describe('Optional dedupe key (idempotency)')
}),
outputSchema: z.object({
notification_ids: z.array(uuidSchema),
delivered_count: z.number().int()
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: (input: any, ctx: ActionContext) => input.dedupe_key ? String(input.dedupe_key) : actionProvidedKey(input, ctx) },
ui: { label: 'Send In-App Notification', category: 'Business Operations', description: 'Create internal_notifications records for users' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
const explicitUserIds = Array.isArray(input.recipients?.user_ids) ? input.recipients.user_ids : [];
const roleIds = Array.isArray(input.recipients?.role_ids) ? input.recipients.role_ids : [];
const roleNames = Array.isArray(input.recipients?.role_names) ? input.recipients.role_names : [];
const resolvedRoleIds: string[] = [];
if (roleIds.length) resolvedRoleIds.push(...roleIds);
if (roleNames.length) {
const roleNamesLower = roleNames.map((n) => n.toLowerCase());
const roles = await tx.trx('roles')
.where({ tenant: tx.tenantId })
.andWhere(function matchRoleNames() {
roleNamesLower.forEach((name) => {
this.orWhereRaw('lower(role_name) = ?', [name]);
});
})
.select('role_id');
resolvedRoleIds.push(...roles.map((r: any) => r.role_id));
}
const roleUserIds: string[] = resolvedRoleIds.length
? (await tx.trx('user_roles')
.where({ tenant: tx.tenantId })
.whereIn('role_id', resolvedRoleIds)
.select('user_id'))
.map((row: any) => row.user_id)
: [];
const userIds = Array.from(new Set([...explicitUserIds, ...roleUserIds]));
if (!userIds.length) {
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'At least one recipient user_id is required' });
}
const existingUsers = await tx.trx('users')
.where({ tenant: tx.tenantId })
.whereIn('user_id', userIds)
.select('user_id');
const existingSet = new Set(existingUsers.map((u: any) => u.user_id));
const missing = userIds.filter((id: string) => !existingSet.has(id));
if (missing.length) {
throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'One or more users not found', details: { missing_user_ids: missing } });
}
const nowIso = new Date().toISOString();
const ids: string[] = [];
for (const userId of userIds) {
const notificationId = uuidv4();
ids.push(notificationId);
await tx.trx('internal_notifications').insert({
internal_notification_id: notificationId,
tenant: tx.tenantId,
user_id: userId,
template_name: 'workflow-custom',
language_code: 'en',
title: input.title,
message: input.body,
type: input.severity,
category: 'workflow',
link: input.link ?? null,
metadata: { source: 'workflow', run_id: ctx.runId, step_path: ctx.stepPath },
is_read: false,
delivery_status: 'pending',
delivery_attempts: 0,
created_at: nowIso,
updated_at: nowIso
});
}
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:notifications.send_in_app',
changedData: { delivered_count: ids.length },
details: { action_id: 'notifications.send_in_app', action_version: 1, delivered_count: ids.length }
});
return { notification_ids: ids, delivered_count: ids.length };
})
});
}