Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -1,11 +1,11 @@
'use client';
import type { CmsFieldType } from '../schema/module.schema';
import { Input } from '@kit/ui/input';
import { Textarea } from '@kit/ui/textarea';
import { Checkbox } from '@kit/ui/checkbox';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Textarea } from '@kit/ui/textarea';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldRendererProps {
name: string;
@@ -49,7 +49,15 @@ export function FieldRenderer({
return (
<Input
name={name}
type={fieldType === 'color' ? 'color' : fieldType === 'url' ? 'url' : fieldType === 'phone' ? 'tel' : 'text'}
type={
fieldType === 'color'
? 'color'
: fieldType === 'url'
? 'url'
: fieldType === 'phone'
? 'tel'
: 'text'
}
value={fieldValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
@@ -133,7 +141,9 @@ export function FieldRenderer({
step="0.01"
value={fieldValue}
onChange={(e) => onChange(parseFloat(e.target.value) || '')}
placeholder={placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)}
placeholder={
placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)
}
readOnly={readonly}
required={required}
/>
@@ -185,7 +195,7 @@ export function FieldRenderer({
onChange={(e) => onChange(e.target.value)}
disabled={readonly}
required={required}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
className="border-input bg-background ring-offset-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="">{placeholder ?? 'Bitte wählen...'}</option>
{selectOptions?.map((opt) => (
@@ -202,7 +212,9 @@ export function FieldRenderer({
name={name}
type="text"
value={fieldValue}
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
onChange={(e) =>
onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))
}
placeholder={placeholder ?? 'DE89 3704 0044 0532 0130 00'}
readOnly={readonly}
required={required}
@@ -229,7 +241,7 @@ export function FieldRenderer({
case 'computed':
return (
<div className="rounded-md border bg-muted px-3 py-2 text-sm">
<div className="bg-muted rounded-md border px-3 py-2 text-sm">
{fieldValue || '—'}
</div>
);
@@ -253,8 +265,10 @@ export function FieldRenderer({
return (
<div className="space-y-1">
{renderField()}
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{helpText && (
<p className="text-muted-foreground text-xs">{helpText}</p>
)}
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}
@@ -266,8 +280,8 @@ export function FieldRenderer({
{required && <span className="text-destructive ml-1">*</span>}
</Label>
{renderField()}
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{helpText && <p className="text-muted-foreground text-xs">{helpText}</p>}
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}

View File

@@ -2,9 +2,8 @@
import { useState } from 'react';
import { FieldRenderer } from './field-renderer';
import type { CmsFieldType } from '../schema/module.schema';
import { FieldRenderer } from './field-renderer';
interface FieldDefinition {
name: string;
@@ -40,7 +39,8 @@ export function ModuleForm({
isLoading = false,
errors = [],
}: ModuleFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
const [formData, setFormData] =
useState<Record<string, unknown>>(initialData);
const visibleFields = fields
.filter((f) => f.show_in_form)
@@ -71,10 +71,14 @@ export function ModuleForm({
const getWidthClass = (width: string) => {
switch (width) {
case 'half': return 'col-span-1';
case 'third': return 'col-span-1';
case 'quarter': return 'col-span-1';
default: return 'col-span-full';
case 'half':
return 'col-span-1';
case 'third':
return 'col-span-1';
case 'quarter':
return 'col-span-1';
default:
return 'col-span-full';
}
};
@@ -83,11 +87,11 @@ export function ModuleForm({
{Array.from(sections.entries()).map(([sectionName, sectionFields]) => (
<div key={sectionName} className="space-y-4">
{sectionName !== 'default' && (
<h3 className="text-lg font-semibold border-b pb-2">
<h3 className="border-b pb-2 text-lg font-semibold">
{sectionName}
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{sectionFields.map((field) => (
<div key={field.name} className={getWidthClass(field.width)}>
<FieldRenderer
@@ -109,11 +113,11 @@ export function ModuleForm({
</div>
))}
<div className="flex justify-end gap-2 pt-4 border-t">
<div className="flex justify-end gap-2 border-t pt-4">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
</button>

View File

@@ -57,7 +57,10 @@ export function ModuleSearch({
const addFilter = () => {
if (filterableFields.length === 0) return;
setFilters([...filters, { field: filterableFields[0]!.name, operator: 'eq', value: '' }]);
setFilters([
...filters,
{ field: filterableFields[0]!.name, operator: 'eq', value: '' },
]);
};
const removeFilter = (index: number) => {
@@ -66,14 +69,22 @@ export function ModuleSearch({
onFilter(next);
};
const updateFilter = (index: number, key: keyof FilterValue, value: string) => {
const updateFilter = (
index: number,
key: keyof FilterValue,
value: string,
) => {
const next = [...filters];
next[index] = { ...next[index]!, [key]: value };
setFilters(next);
};
const applyFilters = () => {
onFilter(filters.filter((f) => f.value || f.operator === 'is_null' || f.operator === 'not_null'));
onFilter(
filters.filter(
(f) => f.value || f.operator === 'is_null' || f.operator === 'not_null',
),
);
};
return (
@@ -88,7 +99,7 @@ export function ModuleSearch({
/>
<button
type="submit"
className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-2 text-sm"
>
Suchen
</button>
@@ -96,7 +107,7 @@ export function ModuleSearch({
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Filter {showAdvanced ? '▲' : '▼'}
</button>
@@ -104,35 +115,40 @@ export function ModuleSearch({
</form>
{showAdvanced && (
<div className="rounded-md border p-4 space-y-3">
<div className="space-y-3 rounded-md border p-4">
{filters.map((filter, i) => (
<div key={i} className="flex gap-2 items-center">
<div key={i} className="flex items-center gap-2">
<select
value={filter.field}
onChange={(e) => updateFilter(i, 'field', e.target.value)}
className="rounded-md border px-2 py-1.5 text-sm bg-background"
className="bg-background rounded-md border px-2 py-1.5 text-sm"
>
{filterableFields.map((f) => (
<option key={f.name} value={f.name}>{f.display_name}</option>
<option key={f.name} value={f.name}>
{f.display_name}
</option>
))}
</select>
<select
value={filter.operator}
onChange={(e) => updateFilter(i, 'operator', e.target.value)}
className="rounded-md border px-2 py-1.5 text-sm bg-background"
className="bg-background rounded-md border px-2 py-1.5 text-sm"
>
{OPERATORS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
{filter.operator !== 'is_null' && filter.operator !== 'not_null' && (
<Input
value={filter.value}
onChange={(e) => updateFilter(i, 'value', e.target.value)}
placeholder="Wert..."
className="max-w-xs"
/>
)}
{filter.operator !== 'is_null' &&
filter.operator !== 'not_null' && (
<Input
value={filter.value}
onChange={(e) => updateFilter(i, 'value', e.target.value)}
placeholder="Wert..."
className="max-w-xs"
/>
)}
<button
type="button"
onClick={() => removeFilter(i)}
@@ -146,7 +162,7 @@ export function ModuleSearch({
<button
type="button"
onClick={addFilter}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-1.5 text-sm"
>
+ Filter hinzufügen
</button>
@@ -154,7 +170,7 @@ export function ModuleSearch({
<button
type="button"
onClick={applyFilters}
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1.5 text-sm"
>
Filter anwenden
</button>

View File

@@ -1,5 +1,7 @@
'use client';
import { formatDate } from '@kit/shared/dates';
import type { CmsFieldType } from '../schema/module.schema';
interface FieldDefinition {
@@ -65,9 +67,12 @@ export function ModuleTable({
case 'checkbox':
return value ? '✓' : '✗';
case 'currency':
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value));
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(Number(value));
case 'date':
try { return new Date(String(value)).toLocaleDateString('de-DE'); } catch { return String(value); }
return formatDate(value as string | Date | null | undefined);
case 'password':
return '••••••';
default:
@@ -76,22 +81,27 @@ export function ModuleTable({
};
const handleSort = (fieldName: string) => {
const newDirection = currentSort?.field === fieldName && currentSort.direction === 'asc'
? 'desc' : 'asc';
const newDirection =
currentSort?.field === fieldName && currentSort.direction === 'asc'
? 'desc'
: 'asc';
onSort(fieldName, newDirection);
};
return (
<div className="space-y-4">
<div className="rounded-md border overflow-auto">
<div className="overflow-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
{onSelectionChange && (
<th className="p-3 w-10">
<th className="w-10 p-3">
<input
type="checkbox"
checked={records.length > 0 && records.every((r) => selectedIds.has(r.id))}
checked={
records.length > 0 &&
records.every((r) => selectedIds.has(r.id))
}
onChange={(e) => {
if (e.target.checked) {
onSelectionChange(new Set(records.map((r) => r.id)));
@@ -105,13 +115,15 @@ export function ModuleTable({
{visibleFields.map((field) => (
<th
key={field.name}
className="p-3 text-left font-medium cursor-pointer hover:bg-muted/80 select-none"
className="hover:bg-muted/80 cursor-pointer p-3 text-left font-medium select-none"
onClick={() => field.is_sortable && handleSort(field.name)}
>
<span className="flex items-center gap-1">
{field.display_name}
{currentSort?.field === field.name && (
<span className="text-xs">{currentSort.direction === 'asc' ? '↑' : '↓'}</span>
<span className="text-xs">
{currentSort.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
</th>
@@ -123,7 +135,7 @@ export function ModuleTable({
<tr>
<td
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
className="p-8 text-center text-muted-foreground"
className="text-muted-foreground p-8 text-center"
>
Keine Datensätze gefunden
</td>
@@ -132,7 +144,7 @@ export function ModuleTable({
records.map((record) => (
<tr
key={record.id}
className="border-b hover:bg-muted/30 cursor-pointer transition-colors"
className="hover:bg-muted/30 cursor-pointer border-b transition-colors"
onClick={() => onRowClick?.(record.id)}
>
{onSelectionChange && (
@@ -154,7 +166,10 @@ export function ModuleTable({
)}
{visibleFields.map((field) => (
<td key={field.name} className="p-3">
{formatCellValue(record.data[field.name], field.field_type)}
{formatCellValue(
record.data[field.name],
field.field_type,
)}
</td>
))}
</tr>
@@ -167,21 +182,22 @@ export function ModuleTable({
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-muted-foreground">
{pagination.total} Datensätze Seite {pagination.page} von {pagination.totalPages}
<span className="text-muted-foreground text-sm">
{pagination.total} Datensätze Seite {pagination.page} von{' '}
{pagination.totalPages}
</span>
<div className="flex gap-1">
<button
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Zurück
</button>
<button
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="rounded border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
className="hover:bg-muted rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Weiter
</button>

View File

@@ -47,7 +47,7 @@ export function ModuleToolbar({
{permissions.canInsert && (
<button
onClick={onNew}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-2 text-sm font-medium"
>
+ Neu
</button>
@@ -56,7 +56,7 @@ export function ModuleToolbar({
{selectedCount > 0 && permissions.canDelete && (
<button
onClick={onDelete}
className="rounded-md border border-destructive px-3 py-2 text-sm text-destructive hover:bg-destructive/10"
className="border-destructive text-destructive hover:bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
Löschen ({selectedCount})
</button>
@@ -65,7 +65,7 @@ export function ModuleToolbar({
{selectedCount > 0 && permissions.canLock && features.enableLock && (
<button
onClick={onLock}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Sperren ({selectedCount})
</button>
@@ -76,7 +76,7 @@ export function ModuleToolbar({
{permissions.canImport && features.enableImport && (
<button
onClick={onImport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Import
</button>
@@ -85,7 +85,7 @@ export function ModuleToolbar({
{permissions.canExport && features.enableExport && (
<button
onClick={onExport}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Export
</button>
@@ -94,7 +94,7 @@ export function ModuleToolbar({
{permissions.canPrint && features.enablePrint && (
<button
onClick={onPrint}
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
className="hover:bg-muted rounded-md border px-3 py-2 text-sm"
>
Drucken
</button>

View File

@@ -7,9 +7,14 @@ import { CmsFieldTypeEnum } from './module.schema';
*/
export const CreateFieldSchema = z.object({
moduleId: z.string().uuid(),
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/, {
message: 'Field name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, {
message:
'Field name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
displayName: z.string().min(1).max(128),
fieldType: CmsFieldTypeEnum,
sqlType: z.string().max(64).default('text'),
@@ -53,10 +58,14 @@ export const CreateFieldSchema = z.object({
lookupValueField: z.string().optional(),
// Select options
selectOptions: z.array(z.object({
label: z.string(),
value: z.string(),
})).optional(),
selectOptions: z
.array(
z.object({
label: z.string(),
value: z.string(),
}),
)
.optional(),
// GDPR
isPersonalData: z.boolean().default(false),

View File

@@ -6,7 +6,9 @@ import { z } from 'zod';
export const ColumnMappingSchema = z.object({
sourceColumn: z.string(),
targetField: z.string(),
transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy']).default('none'),
transform: z
.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy'])
.default('none'),
});
export type ColumnMapping = z.infer<typeof ColumnMappingSchema>;

View File

@@ -5,7 +5,17 @@ import { z } from 'zod';
*/
export const FilterSchema = z.object({
field: z.string().min(1),
operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'is_null', 'not_null']),
operator: z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'like',
'is_null',
'not_null',
]),
value: z.string().optional(),
});

View File

@@ -5,10 +5,26 @@ import { z } from 'zod';
* Maps 1:1 to the cms_field_type Postgres enum.
*/
export const CmsFieldTypeEnum = z.enum([
'text', 'textarea', 'richtext', 'checkbox', 'radio', 'hidden',
'select', 'password', 'file', 'date', 'time', 'decimal',
'integer', 'email', 'phone', 'url', 'currency', 'iban',
'color', 'computed',
'text',
'textarea',
'richtext',
'checkbox',
'radio',
'hidden',
'select',
'password',
'file',
'date',
'time',
'decimal',
'integer',
'email',
'phone',
'url',
'currency',
'iban',
'color',
'computed',
]);
export type CmsFieldType = z.infer<typeof CmsFieldTypeEnum>;
@@ -16,7 +32,12 @@ export type CmsFieldType = z.infer<typeof CmsFieldTypeEnum>;
export const CmsModuleStatusEnum = z.enum(['active', 'inactive', 'archived']);
export type CmsModuleStatus = z.infer<typeof CmsModuleStatusEnum>;
export const CmsRecordStatusEnum = z.enum(['active', 'locked', 'deleted', 'archived']);
export const CmsRecordStatusEnum = z.enum([
'active',
'locked',
'deleted',
'archived',
]);
export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
/**
@@ -24,9 +45,14 @@ export type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
*/
export const CreateModuleSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9_]*$/, {
message: 'Module name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, {
message:
'Module name must start with a letter and contain only lowercase letters, numbers, and underscores',
}),
displayName: z.string().min(1).max(128),
description: z.string().max(1024).optional(),
icon: z.string().max(64).default('table'),

View File

@@ -4,7 +4,10 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateModuleSchema, UpdateModuleSchema } from '../../schema/module.schema';
import {
CreateModuleSchema,
UpdateModuleSchema,
} from '../../schema/module.schema';
import { createModuleBuilderApi } from '../api';
export const createModule = authActionClient
@@ -14,11 +17,17 @@ export const createModule = authActionClient
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.create', moduleName: input.name }, 'Creating module...');
logger.info(
{ name: 'modules.create', moduleName: input.name },
'Creating module...',
);
const module = await api.modules.createModule(input);
logger.info({ name: 'modules.create', moduleId: module.id }, 'Module created');
logger.info(
{ name: 'modules.create', moduleId: module.id },
'Module created',
);
return { success: true, module };
});
@@ -30,11 +39,17 @@ export const updateModule = authActionClient
const logger = await getLogger();
const api = createModuleBuilderApi(client);
logger.info({ name: 'modules.update', moduleId: input.moduleId }, 'Updating module...');
logger.info(
{ name: 'modules.update', moduleId: input.moduleId },
'Updating module...',
);
const module = await api.modules.updateModule(input);
logger.info({ name: 'modules.update', moduleId: module.id }, 'Module updated');
logger.info(
{ name: 'modules.update', moduleId: module.id },
'Module updated',
);
return { success: true, module };
});

View File

@@ -6,7 +6,12 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreateRecordSchema, UpdateRecordSchema, DeleteRecordSchema, LockRecordSchema } from '../../schema/module-record.schema';
import {
CreateRecordSchema,
UpdateRecordSchema,
DeleteRecordSchema,
LockRecordSchema,
} from '../../schema/module-record.schema';
import { createModuleBuilderApi } from '../api';
import { validateRecordData } from '../services/record-validation.service';
@@ -19,14 +24,28 @@ export const createRecord = authActionClient
const userId = ctx.user.id;
// Get field definitions for validation
const moduleWithFields = await api.modules.getModuleWithFields(input.moduleId);
const moduleWithFields = await api.modules.getModuleWithFields(
input.moduleId,
);
if (!moduleWithFields) {
throw new Error('Module not found');
}
// Validate data against field definitions
const fields = (moduleWithFields as unknown as { fields: Array<{ name: string; field_type: string; is_required: boolean; min_value?: number | null; max_value?: number | null; max_length?: number | null; regex_pattern?: string | null }> }).fields;
const fields = (
moduleWithFields as unknown as {
fields: Array<{
name: string;
field_type: string;
is_required: boolean;
min_value?: number | null;
max_value?: number | null;
max_length?: number | null;
regex_pattern?: string | null;
}>;
}
).fields;
const validation = validateRecordData(
input.data as Record<string, unknown>,
fields as Parameters<typeof validateRecordData>[1],
@@ -36,7 +55,10 @@ export const createRecord = authActionClient
return { success: false, errors: validation.errors };
}
logger.info({ name: 'records.create', moduleId: input.moduleId }, 'Creating record...');
logger.info(
{ name: 'records.create', moduleId: input.moduleId },
'Creating record...',
);
const record = await api.records.createRecord(input, userId);
@@ -64,7 +86,10 @@ export const updateRecord = authActionClient
// Get existing record for audit
const existing = await api.records.getRecord(input.recordId);
logger.info({ name: 'records.update', recordId: input.recordId }, 'Updating record...');
logger.info(
{ name: 'records.update', recordId: input.recordId },
'Updating record...',
);
const record = await api.records.updateRecord(input, userId);
@@ -93,7 +118,10 @@ export const deleteRecord = authActionClient
// Get existing record for audit
const existing = await api.records.getRecord(input.recordId);
logger.info({ name: 'records.delete', recordId: input.recordId, hard: input.hard }, 'Deleting record...');
logger.info(
{ name: 'records.delete', recordId: input.recordId, hard: input.hard },
'Deleting record...',
);
await api.records.deleteRecord(input);

View File

@@ -2,10 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createAuditService } from './services/audit.service';
import { createModuleDefinitionService } from './services/module-definition.service';
import { createModuleQueryService } from './services/module-query.service';
import { createRecordCrudService } from './services/record-crud.service';
import { createAuditService } from './services/audit.service';
/**
* Factory for the Module Builder API.

View File

@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
type Json = Database['public']['Tables']['audit_log']['Insert']['old_data'];
@@ -26,7 +27,11 @@ export function createAuditService(client: SupabaseClient<Database>) {
});
if (error) {
console.error('[audit] Failed to write audit log:', error.message);
const logger = await getLogger();
logger.error(
{ error, context: 'audit-log' },
'[audit] Failed to write audit log',
);
}
},
};

View File

@@ -1,13 +1,19 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateModuleInput, UpdateModuleInput } from '../../schema/module.schema';
import type { Database } from '@kit/supabase/database';
import type {
CreateModuleInput,
UpdateModuleInput,
} from '../../schema/module.schema';
/**
* Service for managing module definitions (CRUD).
* Note: Uses untyped client for new CMS tables until typegen runs.
*/
export function createModuleDefinitionService(client: SupabaseClient<Database>) {
export function createModuleDefinitionService(
client: SupabaseClient<Database>,
) {
return {
async listModules(accountId: string) {
const { data, error } = await client
@@ -78,24 +84,40 @@ export function createModuleDefinitionService(client: SupabaseClient<Database>)
async updateModule(input: UpdateModuleInput) {
const updateData: Record<string, unknown> = {};
if (input.displayName !== undefined) updateData.display_name = input.displayName;
if (input.description !== undefined) updateData.description = input.description;
if (input.displayName !== undefined)
updateData.display_name = input.displayName;
if (input.description !== undefined)
updateData.description = input.description;
if (input.icon !== undefined) updateData.icon = input.icon;
if (input.status !== undefined) updateData.status = input.status;
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
if (input.defaultSortField !== undefined) updateData.default_sort_field = input.defaultSortField;
if (input.defaultSortDirection !== undefined) updateData.default_sort_direction = input.defaultSortDirection;
if (input.defaultPageSize !== undefined) updateData.default_page_size = input.defaultPageSize;
if (input.enableSearch !== undefined) updateData.enable_search = input.enableSearch;
if (input.enableFilter !== undefined) updateData.enable_filter = input.enableFilter;
if (input.enableExport !== undefined) updateData.enable_export = input.enableExport;
if (input.enableImport !== undefined) updateData.enable_import = input.enableImport;
if (input.enablePrint !== undefined) updateData.enable_print = input.enablePrint;
if (input.enableCopy !== undefined) updateData.enable_copy = input.enableCopy;
if (input.enableBulkEdit !== undefined) updateData.enable_bulk_edit = input.enableBulkEdit;
if (input.enableHistory !== undefined) updateData.enable_history = input.enableHistory;
if (input.enableSoftDelete !== undefined) updateData.enable_soft_delete = input.enableSoftDelete;
if (input.enableLock !== undefined) updateData.enable_lock = input.enableLock;
if (input.sortOrder !== undefined)
updateData.sort_order = input.sortOrder;
if (input.defaultSortField !== undefined)
updateData.default_sort_field = input.defaultSortField;
if (input.defaultSortDirection !== undefined)
updateData.default_sort_direction = input.defaultSortDirection;
if (input.defaultPageSize !== undefined)
updateData.default_page_size = input.defaultPageSize;
if (input.enableSearch !== undefined)
updateData.enable_search = input.enableSearch;
if (input.enableFilter !== undefined)
updateData.enable_filter = input.enableFilter;
if (input.enableExport !== undefined)
updateData.enable_export = input.enableExport;
if (input.enableImport !== undefined)
updateData.enable_import = input.enableImport;
if (input.enablePrint !== undefined)
updateData.enable_print = input.enablePrint;
if (input.enableCopy !== undefined)
updateData.enable_copy = input.enableCopy;
if (input.enableBulkEdit !== undefined)
updateData.enable_bulk_edit = input.enableBulkEdit;
if (input.enableHistory !== undefined)
updateData.enable_history = input.enableHistory;
if (input.enableSoftDelete !== undefined)
updateData.enable_soft_delete = input.enableSoftDelete;
if (input.enableLock !== undefined)
updateData.enable_lock = input.enableLock;
const { data, error } = await client
.from('modules')

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { ModuleQueryInput } from '../../schema/module-query.schema';
/**

View File

@@ -1,7 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateRecordInput, UpdateRecordInput, DeleteRecordInput, LockRecordInput } from '../../schema/module-record.schema';
import type {
CreateRecordInput,
UpdateRecordInput,
DeleteRecordInput,
LockRecordInput,
} from '../../schema/module-record.schema';
type Json = Database['public']['Tables']['module_records']['Insert']['data'];

View File

@@ -88,15 +88,19 @@ export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
case 'integer':
schema = z.coerce.number().int();
if (field.min_value != null) schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null) schema = (schema as z.ZodNumber).max(field.max_value);
if (field.min_value != null)
schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null)
schema = (schema as z.ZodNumber).max(field.max_value);
break;
case 'decimal':
case 'currency':
schema = z.coerce.number();
if (field.min_value != null) schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null) schema = (schema as z.ZodNumber).max(field.max_value);
if (field.min_value != null)
schema = (schema as z.ZodNumber).min(field.min_value);
if (field.max_value != null)
schema = (schema as z.ZodNumber).max(field.max_value);
break;
case 'date':
@@ -140,7 +144,9 @@ export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
/**
* Build a complete Zod object schema from an array of field definitions.
*/
export function buildRecordValidator(fields: FieldDefinition[]): z.ZodObject<Record<string, z.ZodTypeAny>> {
export function buildRecordValidator(
fields: FieldDefinition[],
): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of fields) {