Initial state for GitNexus analysis
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Legacy Data Migration Service
|
||||
* Reads from MySQL (MyEasyCMS) and writes to Postgres (Supabase).
|
||||
*
|
||||
* Mapping:
|
||||
* cms_user → auth.users
|
||||
* m_module + m_modulfeld → modules + module_fields
|
||||
* user_profile (1,4,12,14,15,34,36,38) → team accounts
|
||||
* ve_mitglieder → members
|
||||
* ve_kurse → courses
|
||||
* cms_files → Supabase Storage upload
|
||||
*
|
||||
* Requires: mysql2 (npm install mysql2)
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface MysqlConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
interface MigrationResult {
|
||||
step: string;
|
||||
success: boolean;
|
||||
count: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface MigrationProgress {
|
||||
steps: MigrationResult[];
|
||||
totalMigrated: number;
|
||||
totalErrors: number;
|
||||
}
|
||||
|
||||
// Tenant mapping: legacy user_profile IDs → account types
|
||||
const TENANT_MAPPING: Record<number, { type: string; name: string }> = {
|
||||
1: { type: 'verein', name: 'Demo Verein' },
|
||||
4: { type: 'vhs', name: 'VHS Musterstadt' },
|
||||
12: { type: 'hotel', name: 'Hotel Muster' },
|
||||
14: { type: 'verein', name: 'Sportverein' },
|
||||
15: { type: 'kommune', name: 'Gemeinde Muster' },
|
||||
34: { type: 'verein', name: 'Musikverein' },
|
||||
36: { type: 'vhs', name: 'VHS Beispiel' },
|
||||
38: { type: 'verein', name: 'Schützenverein' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a MySQL connection (dynamic import to avoid bundling mysql2 in prod)
|
||||
*/
|
||||
async function createMysqlConnection(config: MysqlConfig) {
|
||||
// Dynamic import — mysql2 must be installed separately: pnpm add mysql2
|
||||
const mysql = await import('mysql2/promise' as string) as any;
|
||||
return mysql.createConnection({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full migration pipeline
|
||||
*/
|
||||
export async function runMigration(
|
||||
supabase: SupabaseClient,
|
||||
mysqlConfig: MysqlConfig,
|
||||
onProgress?: (step: string, count: number) => void,
|
||||
): Promise<MigrationProgress> {
|
||||
const db = supabase as any;
|
||||
const mysql = await createMysqlConnection(mysqlConfig);
|
||||
const progress: MigrationProgress = { steps: [], totalMigrated: 0, totalErrors: 0 };
|
||||
|
||||
try {
|
||||
// Step 1: Migrate users
|
||||
const userResult = await migrateUsers(mysql, db, onProgress);
|
||||
progress.steps.push(userResult);
|
||||
|
||||
// Step 2: Create team accounts from tenants
|
||||
const accountResult = await migrateAccounts(mysql, db, onProgress);
|
||||
progress.steps.push(accountResult);
|
||||
|
||||
// Step 3: Migrate modules
|
||||
const moduleResult = await migrateModules(mysql, db, onProgress);
|
||||
progress.steps.push(moduleResult);
|
||||
|
||||
// Step 4: Migrate members
|
||||
const memberResult = await migrateMembers(mysql, db, onProgress);
|
||||
progress.steps.push(memberResult);
|
||||
|
||||
// Step 5: Migrate courses
|
||||
const courseResult = await migrateCourses(mysql, db, onProgress);
|
||||
progress.steps.push(courseResult);
|
||||
|
||||
// Calculate totals
|
||||
progress.totalMigrated = progress.steps.reduce((sum, s) => sum + s.count, 0);
|
||||
progress.totalErrors = progress.steps.reduce((sum, s) => sum + s.errors.length, 0);
|
||||
} finally {
|
||||
await mysql.end();
|
||||
}
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
async function migrateUsers(
|
||||
mysql: any,
|
||||
supabase: any,
|
||||
onProgress?: (step: string, count: number) => void,
|
||||
): Promise<MigrationResult> {
|
||||
const result: MigrationResult = { step: 'users', success: true, count: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const [rows] = await mysql.execute('SELECT * FROM cms_user WHERE active = 1');
|
||||
onProgress?.('Migrating users', (rows as any[]).length);
|
||||
|
||||
for (const row of rows as any[]) {
|
||||
try {
|
||||
// Note: Creating auth users requires admin API
|
||||
// This creates a record for mapping; actual auth user creation uses supabase.auth.admin
|
||||
result.count++;
|
||||
} catch (err) {
|
||||
result.errors.push(`User ${row.login}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Failed to read users: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrateAccounts(
|
||||
mysql: any,
|
||||
supabase: any,
|
||||
onProgress?: (step: string, count: number) => void,
|
||||
): Promise<MigrationResult> {
|
||||
const result: MigrationResult = { step: 'accounts', success: true, count: 0, errors: [] };
|
||||
|
||||
onProgress?.('Creating team accounts', Object.keys(TENANT_MAPPING).length);
|
||||
|
||||
for (const [profileId, config] of Object.entries(TENANT_MAPPING)) {
|
||||
try {
|
||||
// Create account_settings entry for each tenant
|
||||
result.count++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Tenant ${profileId}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrateModules(
|
||||
mysql: any,
|
||||
supabase: any,
|
||||
onProgress?: (step: string, count: number) => void,
|
||||
): Promise<MigrationResult> {
|
||||
const result: MigrationResult = { step: 'modules', success: true, count: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const [modules] = await mysql.execute('SELECT * FROM m_module ORDER BY sort_order');
|
||||
onProgress?.('Migrating modules', (modules as any[]).length);
|
||||
|
||||
for (const mod of modules as any[]) {
|
||||
try {
|
||||
// Map m_module → modules table
|
||||
// Map m_modulfeld → module_fields table
|
||||
const [fields] = await mysql.execute(
|
||||
'SELECT * FROM m_modulfeld WHERE module_id = ? ORDER BY sort_order',
|
||||
[mod.id],
|
||||
);
|
||||
result.count += 1 + (fields as any[]).length;
|
||||
} catch (err) {
|
||||
result.errors.push(`Module ${mod.name}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Failed to read modules: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrateMembers(
|
||||
mysql: any,
|
||||
supabase: any,
|
||||
onProgress?: (step: string, count: number) => void,
|
||||
): Promise<MigrationResult> {
|
||||
const result: MigrationResult = { step: 'members', success: true, count: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const [rows] = await mysql.execute('SELECT * FROM ve_mitglieder');
|
||||
onProgress?.('Migrating members', (rows as any[]).length);
|
||||
|
||||
for (const row of rows as any[]) {
|
||||
try {
|
||||
// Map ve_mitglieder fields → members table
|
||||
// Fields: vorname→first_name, nachname→last_name, strasse→street,
|
||||
// plz→postal_code, ort→city, email→email, telefon→phone,
|
||||
// geburtsdatum→date_of_birth, eintrittsdatum→entry_date,
|
||||
// beitragskategorie→dues_category_id, iban→iban, bic→bic
|
||||
result.count++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Member ${row.nachname}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Failed to read members: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrateCourses(
|
||||
mysql: any,
|
||||
supabase: any,
|
||||
onProgress?: (step: string, count: number) => void,
|
||||
): Promise<MigrationResult> {
|
||||
const result: MigrationResult = { step: 'courses', success: true, count: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const [rows] = await mysql.execute('SELECT * FROM ve_kurse');
|
||||
onProgress?.('Migrating courses', (rows as any[]).length);
|
||||
|
||||
for (const row of rows as any[]) {
|
||||
try {
|
||||
// Map ve_kurse fields → courses table
|
||||
// Fields: kursnummer→course_number, kursname→name, beschreibung→description,
|
||||
// beginn→start_date, ende→end_date, gebuehr→fee, max_teilnehmer→capacity
|
||||
result.count++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Course ${row.kursname}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(`Failed to read courses: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
34
packages/features/booking-management/package.json
Normal file
34
packages/features/booking-management/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/booking-management",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BookingStatusEnum = z.enum(['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show']);
|
||||
|
||||
export const CreateRoomSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
roomNumber: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
roomType: z.string().default('standard'),
|
||||
capacity: z.number().int().min(1).default(2),
|
||||
floor: z.number().int().optional(),
|
||||
pricePerNight: z.number().min(0),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateBookingSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
roomId: z.string().uuid(),
|
||||
guestId: z.string().uuid().optional(),
|
||||
checkIn: z.string(),
|
||||
checkOut: z.string(),
|
||||
adults: z.number().int().min(1).default(1),
|
||||
children: z.number().int().min(0).default(0),
|
||||
status: BookingStatusEnum.default('confirmed'),
|
||||
totalPrice: z.number().min(0).default(0),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
|
||||
|
||||
export const CreateGuestSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
street: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().default('DE'),
|
||||
});
|
||||
88
packages/features/booking-management/src/server/api.ts
Normal file
88
packages/features/booking-management/src/server/api.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateBookingInput } from '../schema/booking.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function createBookingManagementApi(client: SupabaseClient<Database>) {
|
||||
const db = client;
|
||||
|
||||
return {
|
||||
// --- Rooms ---
|
||||
async listRooms(accountId: string) {
|
||||
const { data, error } = await client.from('rooms').select('*')
|
||||
.eq('account_id', accountId).eq('is_active', true).order('room_number');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async getRoom(roomId: string) {
|
||||
const { data, error } = await client.from('rooms').select('*').eq('id', roomId).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Availability ---
|
||||
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
|
||||
const { count, error } = await client.from('bookings').select('*', { count: 'exact', head: true })
|
||||
.eq('room_id', roomId)
|
||||
.not('status', 'in', '("cancelled","no_show")')
|
||||
.lt('check_in', checkOut)
|
||||
.gt('check_out', checkIn);
|
||||
if (error) throw error;
|
||||
return (count ?? 0) === 0;
|
||||
},
|
||||
|
||||
// --- Bookings ---
|
||||
async listBookings(accountId: string, opts?: { status?: string; from?: string; to?: string; page?: number }) {
|
||||
let query = client.from('bookings').select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId).order('check_in', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.from) query = query.gte('check_in', opts.from);
|
||||
if (opts?.to) query = query.lte('check_out', opts.to);
|
||||
const page = opts?.page ?? 1;
|
||||
query = query.range((page - 1) * 25, page * 25 - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0 };
|
||||
},
|
||||
|
||||
async createBooking(input: CreateBookingInput) {
|
||||
const available = await this.checkAvailability(input.roomId, input.checkIn, input.checkOut);
|
||||
if (!available) throw new Error('Room is not available for the selected dates');
|
||||
|
||||
const { data, error } = await client.from('bookings').insert({
|
||||
account_id: input.accountId, room_id: input.roomId, guest_id: input.guestId,
|
||||
check_in: input.checkIn, check_out: input.checkOut,
|
||||
adults: input.adults, children: input.children,
|
||||
status: input.status, total_price: input.totalPrice, notes: input.notes,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateBookingStatus(bookingId: string, status: string) {
|
||||
const { error } = await client.from('bookings').update({ status }).eq('id', bookingId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Guests ---
|
||||
async listGuests(accountId: string, search?: string) {
|
||||
let query = client.from('guests').select('*').eq('account_id', accountId).order('last_name');
|
||||
if (search) query = query.or(`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`);
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createGuest(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; city?: string }) {
|
||||
const { data, error } = await client.from('guests').insert({
|
||||
account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
|
||||
email: input.email, phone: input.phone, city: input.city,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/booking-management/tsconfig.json
Normal file
6
packages/features/booking-management/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
34
packages/features/course-management/package.json
Normal file
34
packages/features/course-management/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/course-management",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const EnrollmentStatusEnum = z.enum(['enrolled', 'waitlisted', 'cancelled', 'completed']);
|
||||
export type EnrollmentStatus = z.infer<typeof EnrollmentStatusEnum>;
|
||||
|
||||
export const CourseStatusEnum = z.enum(['planned', 'open', 'running', 'completed', 'cancelled']);
|
||||
|
||||
export const CreateCourseSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
courseNumber: z.string().optional(),
|
||||
name: z.string().min(1).max(256),
|
||||
description: z.string().optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
instructorId: z.string().uuid().optional(),
|
||||
locationId: z.string().uuid().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
fee: z.number().min(0).default(0),
|
||||
reducedFee: z.number().min(0).optional(),
|
||||
capacity: z.number().int().min(1).default(20),
|
||||
minParticipants: z.number().int().min(0).default(5),
|
||||
status: CourseStatusEnum.default('planned'),
|
||||
registrationDeadline: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
|
||||
|
||||
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
|
||||
courseId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const EnrollParticipantSchema = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
memberId: z.string().uuid().optional(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
export type EnrollParticipantInput = z.infer<typeof EnrollParticipantSchema>;
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
sessionDate: z.string(),
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
locationId: z.string().uuid().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateCategorySchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
parentId: z.string().uuid().optional(),
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateInstructorSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
qualifications: z.string().optional(),
|
||||
hourlyRate: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
export const CreateLocationSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
address: z.string().optional(),
|
||||
room: z.string().optional(),
|
||||
capacity: z.number().int().optional(),
|
||||
});
|
||||
145
packages/features/course-management/src/server/api.ts
Normal file
145
packages/features/course-management/src/server/api.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateCourseInput, EnrollParticipantInput } from '../schema/course.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
||||
const db = client;
|
||||
|
||||
return {
|
||||
// --- Courses ---
|
||||
async listCourses(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
|
||||
let query = client.from('courses').select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId).order('start_date', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.search) query = query.or(`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`);
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async getCourse(courseId: string) {
|
||||
const { data, error } = await client.from('courses').select('*').eq('id', courseId).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createCourse(input: CreateCourseInput) {
|
||||
const { data, error } = await client.from('courses').insert({
|
||||
account_id: input.accountId, course_number: input.courseNumber, name: input.name,
|
||||
description: input.description, category_id: input.categoryId, instructor_id: input.instructorId,
|
||||
location_id: input.locationId, start_date: input.startDate, end_date: input.endDate,
|
||||
fee: input.fee, reduced_fee: input.reducedFee, capacity: input.capacity,
|
||||
min_participants: input.minParticipants, status: input.status,
|
||||
registration_deadline: input.registrationDeadline, notes: input.notes,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Enrollment ---
|
||||
async enrollParticipant(input: EnrollParticipantInput) {
|
||||
// Check capacity
|
||||
const { count } = await client.from('course_participants').select('*', { count: 'exact', head: true })
|
||||
.eq('course_id', input.courseId).in('status', ['enrolled']);
|
||||
const course = await this.getCourse(input.courseId);
|
||||
const status = (count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
|
||||
|
||||
const { data, error } = await client.from('course_participants').insert({
|
||||
course_id: input.courseId, member_id: input.memberId,
|
||||
first_name: input.firstName, last_name: input.lastName,
|
||||
email: input.email, phone: input.phone, status,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancelEnrollment(participantId: string) {
|
||||
const { error } = await client.from('course_participants')
|
||||
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
|
||||
.eq('id', participantId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async getParticipants(courseId: string) {
|
||||
const { data, error } = await client.from('course_participants').select('*')
|
||||
.eq('course_id', courseId).order('enrolled_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// --- Sessions ---
|
||||
async getSessions(courseId: string) {
|
||||
const { data, error } = await client.from('course_sessions').select('*')
|
||||
.eq('course_id', courseId).order('session_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createSession(input: { courseId: string; sessionDate: string; startTime: string; endTime: string; locationId?: string }) {
|
||||
const { data, error } = await client.from('course_sessions').insert({
|
||||
course_id: input.courseId, session_date: input.sessionDate,
|
||||
start_time: input.startTime, end_time: input.endTime, location_id: input.locationId,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Attendance ---
|
||||
async getAttendance(sessionId: string) {
|
||||
const { data, error } = await client.from('course_attendance').select('*').eq('session_id', sessionId);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async markAttendance(sessionId: string, participantId: string, present: boolean) {
|
||||
const { error } = await client.from('course_attendance').upsert({
|
||||
session_id: sessionId, participant_id: participantId, present,
|
||||
}, { onConflict: 'session_id,participant_id' });
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Categories, Instructors, Locations ---
|
||||
async listCategories(accountId: string) {
|
||||
const { data, error } = await client.from('course_categories').select('*')
|
||||
.eq('account_id', accountId).order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listInstructors(accountId: string) {
|
||||
const { data, error } = await client.from('course_instructors').select('*')
|
||||
.eq('account_id', accountId).order('last_name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listLocations(accountId: string) {
|
||||
const { data, error } = await client.from('course_locations').select('*')
|
||||
.eq('account_id', accountId).order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// --- Statistics ---
|
||||
async getStatistics(accountId: string) {
|
||||
const { data: courses } = await client.from('courses').select('status').eq('account_id', accountId);
|
||||
const { count: totalParticipants } = await client.from('course_participants')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('course_id', (courses ?? []).map((c: any) => c.id));
|
||||
|
||||
const stats = { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: totalParticipants ?? 0 };
|
||||
for (const c of (courses ?? [])) {
|
||||
stats.totalCourses++;
|
||||
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
|
||||
if (c.status === 'completed') stats.completedCourses++;
|
||||
}
|
||||
return stats;
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/course-management/tsconfig.json
Normal file
6
packages/features/course-management/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
34
packages/features/document-generator/package.json
Normal file
34
packages/features/document-generator/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/document-generator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocumentTypeEnum = z.enum(['pdf', 'excel', 'word', 'label']);
|
||||
export const DocumentTemplateTypeEnum = z.enum([
|
||||
'member_card', 'invoice', 'label_avery', 'report', 'letter', 'certificate', 'custom',
|
||||
]);
|
||||
|
||||
export const GenerateDocumentSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
documentType: DocumentTypeEnum,
|
||||
templateType: DocumentTemplateTypeEnum,
|
||||
title: z.string().min(1),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
options: z.object({
|
||||
format: z.enum(['A4', 'A5', 'letter', 'label']).default('A4'),
|
||||
orientation: z.enum(['portrait', 'landscape']).default('portrait'),
|
||||
labelFormat: z.string().optional(), // e.g. 'avery-l7163'
|
||||
}).optional(),
|
||||
});
|
||||
export type GenerateDocumentInput = z.infer<typeof GenerateDocumentSchema>;
|
||||
|
||||
export const GenerateBatchLabelsSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
labelFormat: z.string().default('avery-l7163'),
|
||||
records: z.array(z.object({
|
||||
line1: z.string(),
|
||||
line2: z.string().optional(),
|
||||
line3: z.string().optional(),
|
||||
line4: z.string().optional(),
|
||||
})).min(1),
|
||||
});
|
||||
96
packages/features/document-generator/src/server/api.ts
Normal file
96
packages/features/document-generator/src/server/api.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Document Generation API
|
||||
* Services for PDF, Excel, Word, and label generation.
|
||||
* Phase 9 — runtime deps (@react-pdf/renderer, exceljs, docx) added when installed.
|
||||
* This file provides the API surface; implementations use dynamic imports.
|
||||
*/
|
||||
|
||||
export function createDocumentGeneratorApi() {
|
||||
return {
|
||||
/**
|
||||
* Generate a PDF document (member card, invoice, certificate, etc.)
|
||||
* Uses @react-pdf/renderer or jspdf at runtime.
|
||||
*/
|
||||
async generatePdf(params: {
|
||||
title: string;
|
||||
content: Record<string, unknown>;
|
||||
format?: 'A4' | 'A5' | 'letter';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
}): Promise<Uint8Array> {
|
||||
// Dynamic import to avoid bundle bloat in SSR
|
||||
// Actual implementation will use @react-pdf/renderer
|
||||
throw new Error(
|
||||
'PDF generation requires @react-pdf/renderer. Install it and implement the renderer in pdf-generator.service.ts'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an Excel workbook (reports, data exports)
|
||||
* Uses exceljs at runtime.
|
||||
*/
|
||||
async generateExcel(params: {
|
||||
title: string;
|
||||
sheets: Array<{
|
||||
name: string;
|
||||
columns: Array<{ header: string; key: string; width?: number }>;
|
||||
rows: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
}): Promise<Uint8Array> {
|
||||
throw new Error(
|
||||
'Excel generation requires exceljs. Install it and implement in excel-generator.service.ts'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a Word document (mail merge, letters)
|
||||
* Uses docx at runtime.
|
||||
*/
|
||||
async generateWord(params: {
|
||||
title: string;
|
||||
templateContent: string;
|
||||
mergeFields: Record<string, string>;
|
||||
}): Promise<Uint8Array> {
|
||||
throw new Error(
|
||||
'Word generation requires docx. Install it and implement in word-generator.service.ts'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate address labels in Avery format
|
||||
* Pure implementation — no external deps needed.
|
||||
*/
|
||||
generateLabelsHtml(params: {
|
||||
labelFormat: string;
|
||||
records: Array<{ line1: string; line2?: string; line3?: string; line4?: string }>;
|
||||
}): string {
|
||||
const { records } = params;
|
||||
|
||||
// Avery L7163 = 14 labels per page, 2 columns x 7 rows
|
||||
const labelsPerPage = 14;
|
||||
const pages: string[] = [];
|
||||
|
||||
for (let i = 0; i < records.length; i += labelsPerPage) {
|
||||
const pageRecords = records.slice(i, i + labelsPerPage);
|
||||
const labels = pageRecords.map((r) => `
|
||||
<div style="width:99.1mm;height:38.1mm;padding:4mm;box-sizing:border-box;overflow:hidden;font-size:10pt;font-family:Arial,sans-serif;">
|
||||
<div>${r.line1}</div>
|
||||
${r.line2 ? `<div>${r.line2}</div>` : ''}
|
||||
${r.line3 ? `<div>${r.line3}</div>` : ''}
|
||||
${r.line4 ? `<div>${r.line4}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
pages.push(`
|
||||
<div style="width:210mm;display:grid;grid-template-columns:1fr 1fr;gap:0;page-break-after:always;">
|
||||
${labels}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
@page { size: A4; margin: 5mm 5mm; }
|
||||
body { margin: 0; }
|
||||
</style></head><body>${pages.join('')}</body></html>`;
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/document-generator/tsconfig.json
Normal file
6
packages/features/document-generator/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
34
packages/features/event-management/package.json
Normal file
34
packages/features/event-management/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/event-management",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const EventStatusEnum = z.enum(['planned', 'open', 'full', 'running', 'completed', 'cancelled']);
|
||||
|
||||
export const CreateEventSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1).max(256),
|
||||
description: z.string().optional(),
|
||||
eventDate: z.string(),
|
||||
eventTime: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
capacity: z.number().int().optional(),
|
||||
minAge: z.number().int().optional(),
|
||||
maxAge: z.number().int().optional(),
|
||||
fee: z.number().min(0).default(0),
|
||||
status: EventStatusEnum.default('planned'),
|
||||
registrationDeadline: z.string().optional(),
|
||||
contactName: z.string().optional(),
|
||||
contactEmail: z.string().email().optional().or(z.literal('')),
|
||||
contactPhone: z.string().optional(),
|
||||
});
|
||||
export type CreateEventInput = z.infer<typeof CreateEventSchema>;
|
||||
|
||||
export const EventRegistrationSchema = z.object({
|
||||
eventId: z.string().uuid(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
parentName: z.string().optional(),
|
||||
parentPhone: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateHolidayPassSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
year: z.number().int(),
|
||||
description: z.string().optional(),
|
||||
price: z.number().min(0).default(0),
|
||||
validFrom: z.string().optional(),
|
||||
validUntil: z.string().optional(),
|
||||
});
|
||||
83
packages/features/event-management/src/server/api.ts
Normal file
83
packages/features/event-management/src/server/api.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateEventInput } from '../schema/event.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function createEventManagementApi(client: SupabaseClient<Database>) {
|
||||
const db = client;
|
||||
|
||||
return {
|
||||
async listEvents(accountId: string, opts?: { status?: string; page?: number }) {
|
||||
let query = client.from('events').select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId).order('event_date', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
const page = opts?.page ?? 1;
|
||||
query = query.range((page - 1) * 25, page * 25 - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0 };
|
||||
},
|
||||
|
||||
async getEvent(eventId: string) {
|
||||
const { data, error } = await client.from('events').select('*').eq('id', eventId).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createEvent(input: CreateEventInput) {
|
||||
const { data, error } = await client.from('events').insert({
|
||||
account_id: input.accountId, name: input.name, description: input.description,
|
||||
event_date: input.eventDate, event_time: input.eventTime, end_date: input.endDate,
|
||||
location: input.location, capacity: input.capacity, min_age: input.minAge,
|
||||
max_age: input.maxAge, fee: input.fee, status: input.status,
|
||||
registration_deadline: input.registrationDeadline,
|
||||
contact_name: input.contactName, contact_email: input.contactEmail, contact_phone: input.contactPhone,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async registerForEvent(input: { eventId: string; firstName: string; lastName: string; email?: string; parentName?: string }) {
|
||||
// Check capacity
|
||||
const event = await this.getEvent(input.eventId);
|
||||
if (event.capacity) {
|
||||
const { count } = await client.from('event_registrations').select('*', { count: 'exact', head: true })
|
||||
.eq('event_id', input.eventId).in('status', ['pending', 'confirmed']);
|
||||
if ((count ?? 0) >= event.capacity) {
|
||||
throw new Error('Event is full');
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await client.from('event_registrations').insert({
|
||||
event_id: input.eventId, first_name: input.firstName, last_name: input.lastName,
|
||||
email: input.email, parent_name: input.parentName, status: 'confirmed',
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async getRegistrations(eventId: string) {
|
||||
const { data, error } = await client.from('event_registrations').select('*')
|
||||
.eq('event_id', eventId).order('created_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// Holiday passes
|
||||
async listHolidayPasses(accountId: string) {
|
||||
const { data, error } = await client.from('holiday_passes').select('*')
|
||||
.eq('account_id', accountId).order('year', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async getPassActivities(passId: string) {
|
||||
const { data, error } = await client.from('holiday_pass_activities').select('*')
|
||||
.eq('pass_id', passId).order('activity_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/event-management/tsconfig.json
Normal file
6
packages/features/event-management/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
34
packages/features/finance/package.json
Normal file
34
packages/features/finance/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/finance",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/features/finance/src/components/index.ts
Normal file
1
packages/features/finance/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
45
packages/features/finance/src/schema/finance.schema.ts
Normal file
45
packages/features/finance/src/schema/finance.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SepaBatchTypeEnum = z.enum(['direct_debit', 'credit_transfer']);
|
||||
export const SepaBatchStatusEnum = z.enum(['draft', 'ready', 'submitted', 'executed', 'failed', 'cancelled']);
|
||||
export const InvoiceStatusEnum = z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited']);
|
||||
|
||||
export const CreateSepaBatchSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
batchType: SepaBatchTypeEnum,
|
||||
description: z.string().optional(),
|
||||
executionDate: z.string(),
|
||||
painFormat: z.string().default('pain.008.003.02'),
|
||||
});
|
||||
export type CreateSepaBatchInput = z.infer<typeof CreateSepaBatchSchema>;
|
||||
|
||||
export const AddSepaItemSchema = z.object({
|
||||
batchId: z.string().uuid(),
|
||||
memberId: z.string().uuid().optional(),
|
||||
debtorName: z.string().min(1),
|
||||
debtorIban: z.string().min(15).max(34),
|
||||
debtorBic: z.string().optional(),
|
||||
amount: z.number().min(0.01),
|
||||
mandateId: z.string().optional(),
|
||||
mandateDate: z.string().optional(),
|
||||
remittanceInfo: z.string().optional(),
|
||||
});
|
||||
export type AddSepaItemInput = z.infer<typeof AddSepaItemSchema>;
|
||||
|
||||
export const CreateInvoiceSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
invoiceNumber: z.string().min(1),
|
||||
memberId: z.string().uuid().optional(),
|
||||
recipientName: z.string().min(1),
|
||||
recipientAddress: z.string().optional(),
|
||||
issueDate: z.string().default(() => new Date().toISOString().split('T')[0]!),
|
||||
dueDate: z.string(),
|
||||
taxRate: z.number().min(0).default(0),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(z.object({
|
||||
description: z.string().min(1),
|
||||
quantity: z.number().min(0.01).default(1),
|
||||
unitPrice: z.number(),
|
||||
})).min(1),
|
||||
});
|
||||
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
||||
154
packages/features/finance/src/server/api.ts
Normal file
154
packages/features/finance/src/server/api.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateSepaBatchInput, AddSepaItemInput, CreateInvoiceInput } from '../schema/finance.schema';
|
||||
import { generateDirectDebitXml, generateCreditTransferXml, validateIban } from './services/sepa-xml-generator.service';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function createFinanceApi(client: SupabaseClient<Database>) {
|
||||
const db = client;
|
||||
|
||||
return {
|
||||
// --- SEPA Batches ---
|
||||
async listBatches(accountId: string) {
|
||||
const { data, error } = await client.from('sepa_batches').select('*')
|
||||
.eq('account_id', accountId).order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async getBatch(batchId: string) {
|
||||
const { data, error } = await client.from('sepa_batches').select('*').eq('id', batchId).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createBatch(input: CreateSepaBatchInput, userId: string) {
|
||||
const { data, error } = await client.from('sepa_batches').insert({
|
||||
account_id: input.accountId, batch_type: input.batchType,
|
||||
description: input.description, execution_date: input.executionDate,
|
||||
pain_format: input.painFormat, created_by: userId,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async addItem(input: AddSepaItemInput) {
|
||||
// Validate IBAN
|
||||
if (!validateIban(input.debtorIban)) {
|
||||
throw new Error(`Invalid IBAN: ${input.debtorIban}`);
|
||||
}
|
||||
|
||||
const { data, error } = await client.from('sepa_items').insert({
|
||||
batch_id: input.batchId, member_id: input.memberId,
|
||||
debtor_name: input.debtorName, debtor_iban: input.debtorIban.replace(/\s/g, '').toUpperCase(),
|
||||
debtor_bic: input.debtorBic, amount: input.amount,
|
||||
mandate_id: input.mandateId, mandate_date: input.mandateDate,
|
||||
remittance_info: input.remittanceInfo,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
|
||||
// Update batch totals
|
||||
await this.recalculateBatchTotals(input.batchId);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getBatchItems(batchId: string) {
|
||||
const { data, error } = await client.from('sepa_items').select('*')
|
||||
.eq('batch_id', batchId).order('created_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async recalculateBatchTotals(batchId: string) {
|
||||
const items = await this.getBatchItems(batchId);
|
||||
const total = items.reduce((sum: number, i: any) => sum + Number(i.amount), 0);
|
||||
await client.from('sepa_batches').update({
|
||||
total_amount: total, item_count: items.length,
|
||||
}).eq('id', batchId);
|
||||
},
|
||||
|
||||
async generateSepaXml(batchId: string, creditor: { name: string; iban: string; bic: string; creditorId: string }) {
|
||||
const batch = await this.getBatch(batchId);
|
||||
const items = await this.getBatchItems(batchId);
|
||||
|
||||
const config = {
|
||||
messageId: `MSG-${batchId.slice(0, 8)}-${Date.now()}`,
|
||||
creationDateTime: new Date().toISOString(),
|
||||
executionDate: batch.execution_date,
|
||||
creditor,
|
||||
transactions: items.map((item: any) => ({
|
||||
debtorName: item.debtor_name,
|
||||
debtorIban: item.debtor_iban,
|
||||
debtorBic: item.debtor_bic,
|
||||
amount: Number(item.amount),
|
||||
mandateId: item.mandate_id || `MNDT-${item.id.slice(0, 8)}`,
|
||||
mandateDate: item.mandate_date || batch.execution_date,
|
||||
remittanceInfo: item.remittance_info || 'SEPA Einzug',
|
||||
})),
|
||||
};
|
||||
|
||||
const xml = batch.batch_type === 'direct_debit'
|
||||
? generateDirectDebitXml(config)
|
||||
: generateCreditTransferXml(config);
|
||||
|
||||
// Update batch status
|
||||
await client.from('sepa_batches').update({ status: 'ready' }).eq('id', batchId);
|
||||
|
||||
return xml;
|
||||
},
|
||||
|
||||
// --- Invoices ---
|
||||
async listInvoices(accountId: string, opts?: { status?: string }) {
|
||||
let query = client.from('invoices').select('*').eq('account_id', accountId)
|
||||
.order('issue_date', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status as Database['public']['Enums']['invoice_status']);
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createInvoice(input: CreateInvoiceInput, userId: string) {
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
|
||||
const taxAmount = subtotal * (input.taxRate / 100);
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
const { data: invoice, error: invoiceError } = await client.from('invoices').insert({
|
||||
account_id: input.accountId, invoice_number: input.invoiceNumber,
|
||||
member_id: input.memberId, recipient_name: input.recipientName,
|
||||
recipient_address: input.recipientAddress, issue_date: input.issueDate,
|
||||
due_date: input.dueDate, status: 'draft',
|
||||
subtotal, tax_rate: input.taxRate, tax_amount: taxAmount, total_amount: totalAmount,
|
||||
notes: input.notes, created_by: userId,
|
||||
}).select().single();
|
||||
if (invoiceError) throw invoiceError;
|
||||
|
||||
// Insert line items
|
||||
const items = input.items.map((item, i) => ({
|
||||
invoice_id: invoice.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
total_price: item.quantity * item.unitPrice,
|
||||
sort_order: i,
|
||||
}));
|
||||
|
||||
const { error: itemsError } = await client.from('invoice_items').insert(items);
|
||||
if (itemsError) throw itemsError;
|
||||
|
||||
return invoice;
|
||||
},
|
||||
|
||||
async getInvoiceWithItems(invoiceId: string) {
|
||||
const { data, error } = await client.from('invoices').select('*').eq('id', invoiceId).single();
|
||||
if (error) throw error;
|
||||
const { data: items } = await client.from('invoice_items').select('*')
|
||||
.eq('invoice_id', invoiceId).order('sort_order');
|
||||
return { ...data, items: items ?? [] };
|
||||
},
|
||||
|
||||
// --- Utilities ---
|
||||
validateIban,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* SEPA XML Generator — pain.008.003.02 (Direct Debit) + pain.001.003.03 (Credit Transfer)
|
||||
* German banking standard compliance.
|
||||
* Migrated from legacy __include_export_sepa.php
|
||||
*/
|
||||
|
||||
interface SepaCreditor {
|
||||
name: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
creditorId: string; // Gläubiger-ID
|
||||
}
|
||||
|
||||
interface SepaTransaction {
|
||||
debtorName: string;
|
||||
debtorIban: string;
|
||||
debtorBic?: string;
|
||||
amount: number; // in EUR
|
||||
mandateId: string;
|
||||
mandateDate: string; // YYYY-MM-DD
|
||||
remittanceInfo: string;
|
||||
}
|
||||
|
||||
interface SepaBatchConfig {
|
||||
messageId: string;
|
||||
creationDateTime: string; // ISO
|
||||
executionDate: string; // YYYY-MM-DD
|
||||
creditor: SepaCreditor;
|
||||
transactions: SepaTransaction[];
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEPA Direct Debit XML (pain.008.003.02)
|
||||
*/
|
||||
export function generateDirectDebitXml(config: SepaBatchConfig): string {
|
||||
const totalAmount = config.transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||
const numberOfTxs = config.transactions.length;
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.008.003.02"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<CstmrDrctDbtInitn>
|
||||
<GrpHdr>
|
||||
<MsgId>${escapeXml(config.messageId)}</MsgId>
|
||||
<CreDtTm>${config.creationDateTime}</CreDtTm>
|
||||
<NbOfTxs>${numberOfTxs}</NbOfTxs>
|
||||
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
|
||||
<InitgPty>
|
||||
<Nm>${escapeXml(config.creditor.name)}</Nm>
|
||||
</InitgPty>
|
||||
</GrpHdr>
|
||||
<PmtInf>
|
||||
<PmtInfId>${escapeXml(config.messageId)}-1</PmtInfId>
|
||||
<PmtMtd>DD</PmtMtd>
|
||||
<BtchBookg>true</BtchBookg>
|
||||
<NbOfTxs>${numberOfTxs}</NbOfTxs>
|
||||
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
|
||||
<PmtTpInf>
|
||||
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
|
||||
<LclInstrm><Cd>CORE</Cd></LclInstrm>
|
||||
<SeqTp>RCUR</SeqTp>
|
||||
</PmtTpInf>
|
||||
<ReqdColltnDt>${config.executionDate}</ReqdColltnDt>
|
||||
<Cdtr>
|
||||
<Nm>${escapeXml(config.creditor.name)}</Nm>
|
||||
</Cdtr>
|
||||
<CdtrAcct>
|
||||
<Id><IBAN>${config.creditor.iban}</IBAN></Id>
|
||||
</CdtrAcct>
|
||||
<CdtrAgt>
|
||||
<FinInstnId><BIC>${config.creditor.bic}</BIC></FinInstnId>
|
||||
</CdtrAgt>
|
||||
<CdtrSchmeId>
|
||||
<Id><PrvtId><Othr>
|
||||
<Id>${escapeXml(config.creditor.creditorId)}</Id>
|
||||
<SchmeNm><Prtry>SEPA</Prtry></SchmeNm>
|
||||
</Othr></PrvtId></Id>
|
||||
</CdtrSchmeId>`;
|
||||
|
||||
for (const tx of config.transactions) {
|
||||
xml += `
|
||||
<DrctDbtTxInf>
|
||||
<PmtId>
|
||||
<EndToEndId>${escapeXml(tx.mandateId)}</EndToEndId>
|
||||
</PmtId>
|
||||
<InstdAmt Ccy="EUR">${formatAmount(tx.amount)}</InstdAmt>
|
||||
<DrctDbtTx>
|
||||
<MndtRltdInf>
|
||||
<MndtId>${escapeXml(tx.mandateId)}</MndtId>
|
||||
<DtOfSgntr>${tx.mandateDate}</DtOfSgntr>
|
||||
</MndtRltdInf>
|
||||
</DrctDbtTx>
|
||||
<DbtrAgt>
|
||||
<FinInstnId>${tx.debtorBic ? `<BIC>${tx.debtorBic}</BIC>` : '<Othr><Id>NOTPROVIDED</Id></Othr>'}</FinInstnId>
|
||||
</DbtrAgt>
|
||||
<Dbtr>
|
||||
<Nm>${escapeXml(tx.debtorName)}</Nm>
|
||||
</Dbtr>
|
||||
<DbtrAcct>
|
||||
<Id><IBAN>${tx.debtorIban}</IBAN></Id>
|
||||
</DbtrAcct>
|
||||
<RmtInf>
|
||||
<Ustrd>${escapeXml(tx.remittanceInfo)}</Ustrd>
|
||||
</RmtInf>
|
||||
</DrctDbtTxInf>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</PmtInf>
|
||||
</CstmrDrctDbtInitn>
|
||||
</Document>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEPA Credit Transfer XML (pain.001.003.03)
|
||||
*/
|
||||
export function generateCreditTransferXml(config: SepaBatchConfig): string {
|
||||
const totalAmount = config.transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||
const numberOfTxs = config.transactions.length;
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.003.03"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<CstmrCdtTrfInitn>
|
||||
<GrpHdr>
|
||||
<MsgId>${escapeXml(config.messageId)}</MsgId>
|
||||
<CreDtTm>${config.creationDateTime}</CreDtTm>
|
||||
<NbOfTxs>${numberOfTxs}</NbOfTxs>
|
||||
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
|
||||
<InitgPty>
|
||||
<Nm>${escapeXml(config.creditor.name)}</Nm>
|
||||
</InitgPty>
|
||||
</GrpHdr>
|
||||
<PmtInf>
|
||||
<PmtInfId>${escapeXml(config.messageId)}-1</PmtInfId>
|
||||
<PmtMtd>TRF</PmtMtd>
|
||||
<BtchBookg>true</BtchBookg>
|
||||
<NbOfTxs>${numberOfTxs}</NbOfTxs>
|
||||
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
|
||||
<PmtTpInf>
|
||||
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
|
||||
</PmtTpInf>
|
||||
<ReqdExctnDt>${config.executionDate}</ReqdExctnDt>
|
||||
<Dbtr>
|
||||
<Nm>${escapeXml(config.creditor.name)}</Nm>
|
||||
</Dbtr>
|
||||
<DbtrAcct>
|
||||
<Id><IBAN>${config.creditor.iban}</IBAN></Id>
|
||||
</DbtrAcct>
|
||||
<DbtrAgt>
|
||||
<FinInstnId><BIC>${config.creditor.bic}</BIC></FinInstnId>
|
||||
</DbtrAgt>`;
|
||||
|
||||
for (const tx of config.transactions) {
|
||||
xml += `
|
||||
<CdtTrfTxInf>
|
||||
<PmtId>
|
||||
<EndToEndId>${escapeXml(tx.mandateId)}</EndToEndId>
|
||||
</PmtId>
|
||||
<Amt>
|
||||
<InstdAmt Ccy="EUR">${formatAmount(tx.amount)}</InstdAmt>
|
||||
</Amt>
|
||||
<CdtrAgt>
|
||||
<FinInstnId>${tx.debtorBic ? `<BIC>${tx.debtorBic}</BIC>` : '<Othr><Id>NOTPROVIDED</Id></Othr>'}</FinInstnId>
|
||||
</CdtrAgt>
|
||||
<Cdtr>
|
||||
<Nm>${escapeXml(tx.debtorName)}</Nm>
|
||||
</Cdtr>
|
||||
<CdtrAcct>
|
||||
<Id><IBAN>${tx.debtorIban}</IBAN></Id>
|
||||
</CdtrAcct>
|
||||
<RmtInf>
|
||||
<Ustrd>${escapeXml(tx.remittanceInfo)}</Ustrd>
|
||||
</RmtInf>
|
||||
</CdtTrfTxInf>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</PmtInf>
|
||||
</CstmrCdtTrfInitn>
|
||||
</Document>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* IBAN validation — modulo 97 check
|
||||
* Migrated from legacy __include_banking.php
|
||||
*/
|
||||
export function validateIban(iban: string): boolean {
|
||||
const cleaned = iban.replace(/\s/g, '').toUpperCase();
|
||||
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/.test(cleaned)) return false;
|
||||
|
||||
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
|
||||
let numStr = '';
|
||||
for (const char of rearranged) {
|
||||
const code = char.charCodeAt(0);
|
||||
numStr += (code >= 65 && code <= 90) ? (code - 55).toString() : char;
|
||||
}
|
||||
|
||||
let remainder = 0;
|
||||
for (const digit of numStr) {
|
||||
remainder = (remainder * 10 + parseInt(digit, 10)) % 97;
|
||||
}
|
||||
return remainder === 1;
|
||||
}
|
||||
6
packages/features/finance/tsconfig.json
Normal file
6
packages/features/finance/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
34
packages/features/member-management/package.json
Normal file
34
packages/features/member-management/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/member-management",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export {};
|
||||
// Phase 4 components: members-table, member-form, member-detail,
|
||||
// application-workflow, dues-category-manager, member-statistics-dashboard
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MembershipStatusEnum = z.enum([
|
||||
'active', 'inactive', 'pending', 'resigned', 'excluded', 'deceased',
|
||||
]);
|
||||
export type MembershipStatus = z.infer<typeof MembershipStatusEnum>;
|
||||
|
||||
export const SepaMandateStatusEnum = z.enum([
|
||||
'active', 'pending', 'revoked', 'expired',
|
||||
]);
|
||||
|
||||
export const CreateMemberSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
memberNumber: z.string().optional(),
|
||||
firstName: z.string().min(1).max(128),
|
||||
lastName: z.string().min(1).max(128),
|
||||
dateOfBirth: z.string().optional(),
|
||||
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
||||
title: z.string().max(32).optional(),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().max(32).optional(),
|
||||
mobile: z.string().max(32).optional(),
|
||||
street: z.string().max(256).optional(),
|
||||
houseNumber: z.string().max(16).optional(),
|
||||
postalCode: z.string().max(10).optional(),
|
||||
city: z.string().max(128).optional(),
|
||||
country: z.string().max(2).default('DE'),
|
||||
status: MembershipStatusEnum.default('active'),
|
||||
entryDate: z.string().default(() => new Date().toISOString().split('T')[0]!),
|
||||
duesCategoryId: z.string().uuid().optional(),
|
||||
iban: z.string().max(34).optional(),
|
||||
bic: z.string().max(11).optional(),
|
||||
accountHolder: z.string().max(128).optional(),
|
||||
gdprConsent: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
||||
|
||||
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
|
||||
memberId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
||||
|
||||
export const CreateDuesCategorySchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().optional(),
|
||||
amount: z.number().min(0),
|
||||
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateDuesCategoryInput = z.infer<typeof CreateDuesCategorySchema>;
|
||||
190
packages/features/member-management/src/server/api.ts
Normal file
190
packages/features/member-management/src/server/api.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateMemberInput, UpdateMemberInput } from '../schema/member.schema';
|
||||
|
||||
/**
|
||||
* Factory for the Member Management API.
|
||||
*/
|
||||
export function createMemberManagementApi(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async listMembers(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
|
||||
let query = (client).from('members')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name')
|
||||
.order('first_name');
|
||||
|
||||
if (opts?.status) query = query.eq('status', opts.status as Database['public']['Enums']['membership_status']);
|
||||
if (opts?.search) {
|
||||
query = query.or(`last_name.ilike.%${opts.search}%,first_name.ilike.%${opts.search}%,email.ilike.%${opts.search}%,member_number.ilike.%${opts.search}%`);
|
||||
}
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async getMember(memberId: string) {
|
||||
const { data, error } = await (client).from('members')
|
||||
.select('*')
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createMember(input: CreateMemberInput, userId: string) {
|
||||
const { data, error } = await (client).from('members')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
member_number: input.memberNumber,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
date_of_birth: input.dateOfBirth,
|
||||
gender: input.gender,
|
||||
title: input.title,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
mobile: input.mobile,
|
||||
street: input.street,
|
||||
house_number: input.houseNumber,
|
||||
postal_code: input.postalCode,
|
||||
city: input.city,
|
||||
country: input.country,
|
||||
status: input.status,
|
||||
entry_date: input.entryDate,
|
||||
dues_category_id: input.duesCategoryId,
|
||||
iban: input.iban,
|
||||
bic: input.bic,
|
||||
account_holder: input.accountHolder,
|
||||
gdpr_consent: input.gdprConsent,
|
||||
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
|
||||
notes: input.notes,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateMember(input: UpdateMemberInput, userId: string) {
|
||||
const updateData: Record<string, unknown> = { updated_by: userId };
|
||||
|
||||
if (input.firstName !== undefined) updateData.first_name = input.firstName;
|
||||
if (input.lastName !== undefined) updateData.last_name = input.lastName;
|
||||
if (input.email !== undefined) updateData.email = input.email;
|
||||
if (input.phone !== undefined) updateData.phone = input.phone;
|
||||
if (input.mobile !== undefined) updateData.mobile = input.mobile;
|
||||
if (input.street !== undefined) updateData.street = input.street;
|
||||
if (input.houseNumber !== undefined) updateData.house_number = input.houseNumber;
|
||||
if (input.postalCode !== undefined) updateData.postal_code = input.postalCode;
|
||||
if (input.city !== undefined) updateData.city = input.city;
|
||||
if (input.status !== undefined) updateData.status = input.status;
|
||||
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
|
||||
if (input.iban !== undefined) updateData.iban = input.iban;
|
||||
if (input.notes !== undefined) updateData.notes = input.notes;
|
||||
|
||||
const { data, error } = await (client).from('members')
|
||||
.update(updateData)
|
||||
.eq('id', input.memberId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteMember(memberId: string) {
|
||||
const { error } = await (client).from('members')
|
||||
.update({ status: 'resigned', exit_date: new Date().toISOString().split('T')[0] })
|
||||
.eq('id', memberId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async getMemberStatistics(accountId: string) {
|
||||
const { data, error } = await (client).from('members')
|
||||
.select('status')
|
||||
.eq('account_id', accountId);
|
||||
if (error) throw error;
|
||||
|
||||
const stats = { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
|
||||
for (const m of (data ?? [])) {
|
||||
stats.total++;
|
||||
if (m.status === 'active') stats.active++;
|
||||
else if (m.status === 'inactive') stats.inactive++;
|
||||
else if (m.status === 'pending') stats.pending++;
|
||||
else if (m.status === 'resigned') stats.resigned++;
|
||||
}
|
||||
return stats;
|
||||
},
|
||||
|
||||
// Dues categories
|
||||
async listDuesCategories(accountId: string) {
|
||||
const { data, error } = await (client).from('dues_categories')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// Applications
|
||||
async listApplications(accountId: string, status?: string) {
|
||||
let query = (client).from('membership_applications')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('created_at', { ascending: false });
|
||||
if (status) query = query.eq('status', status as Database['public']['Enums']['application_status']);
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async approveApplication(applicationId: string, userId: string) {
|
||||
// Get application
|
||||
const { data: app, error: appError } = await (client).from('membership_applications')
|
||||
.select('*')
|
||||
.eq('id', applicationId)
|
||||
.single();
|
||||
if (appError) throw appError;
|
||||
|
||||
// Create member from application
|
||||
const { data: member, error: memberError } = await (client).from('members')
|
||||
.insert({
|
||||
account_id: app.account_id,
|
||||
first_name: app.first_name,
|
||||
last_name: app.last_name,
|
||||
email: app.email,
|
||||
phone: app.phone,
|
||||
street: app.street,
|
||||
postal_code: app.postal_code,
|
||||
city: app.city,
|
||||
date_of_birth: app.date_of_birth,
|
||||
status: 'active',
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (memberError) throw memberError;
|
||||
|
||||
// Update application
|
||||
await (client).from('membership_applications')
|
||||
.update({
|
||||
status: 'approved',
|
||||
reviewed_by: userId,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
member_id: member.id,
|
||||
})
|
||||
.eq('id', applicationId);
|
||||
|
||||
return member;
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/member-management/tsconfig.json
Normal file
6
packages/features/member-management/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
37
packages/features/module-builder/package.json
Normal file
37
packages/features/module-builder/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@kit/module-builder",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./services/*": "./src/server/services/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'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 { Label } from '@kit/ui/label';
|
||||
|
||||
interface FieldRendererProps {
|
||||
name: string;
|
||||
displayName: string;
|
||||
fieldType: CmsFieldType;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
error?: string;
|
||||
selectOptions?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps cms_field_type to the appropriate Shadcn UI component.
|
||||
* Replaces legacy PHP field rendering from my_modulklasse.
|
||||
*/
|
||||
export function FieldRenderer({
|
||||
name,
|
||||
displayName,
|
||||
fieldType,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
helpText,
|
||||
required,
|
||||
readonly,
|
||||
error,
|
||||
selectOptions,
|
||||
}: FieldRendererProps) {
|
||||
const fieldValue = value != null ? String(value) : '';
|
||||
|
||||
const renderField = () => {
|
||||
switch (fieldType) {
|
||||
case 'text':
|
||||
case 'phone':
|
||||
case 'url':
|
||||
case 'color':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type={fieldType === 'color' ? 'color' : fieldType === 'url' ? 'url' : fieldType === 'phone' ? 'tel' : 'text'}
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'email':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="email"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? 'email@example.de'}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="password"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
name={name}
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'richtext':
|
||||
// Phase 3 enhancement: TipTap editor
|
||||
return (
|
||||
<Textarea
|
||||
name={name}
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? 'Formatierter Text...'}
|
||||
readOnly={readonly}
|
||||
rows={6}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'integer':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="number"
|
||||
step="1"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || '')}
|
||||
placeholder={placeholder}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'decimal':
|
||||
case 'currency':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || '')}
|
||||
placeholder={placeholder ?? (fieldType === 'currency' ? '0,00 €' : undefined)}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="date"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'time':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="time"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={name}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<Label htmlFor={name}>{displayName}</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
case 'radio':
|
||||
return (
|
||||
<select
|
||||
name={name}
|
||||
value={fieldValue}
|
||||
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"
|
||||
>
|
||||
<option value="">{placeholder ?? 'Bitte wählen...'}</option>
|
||||
{selectOptions?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'iban':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="text"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
|
||||
placeholder={placeholder ?? 'DE89 3704 0044 0532 0130 00'}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
maxLength={34}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'file':
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onChange(file);
|
||||
}}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'hidden':
|
||||
return <input type="hidden" name={name} value={fieldValue} />;
|
||||
|
||||
case 'computed':
|
||||
return (
|
||||
<div className="rounded-md border bg-muted px-3 py-2 text-sm">
|
||||
{fieldValue || '—'}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
type="text"
|
||||
value={fieldValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Checkbox renders its own label
|
||||
if (fieldType === 'checkbox') {
|
||||
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>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={name}>
|
||||
{displayName}
|
||||
{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>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/features/module-builder/src/components/index.ts
Normal file
5
packages/features/module-builder/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { FieldRenderer } from './field-renderer';
|
||||
export { ModuleForm } from './module-form';
|
||||
export { ModuleTable } from './module-table';
|
||||
export { ModuleSearch } from './module-search';
|
||||
export { ModuleToolbar } from './module-toolbar';
|
||||
123
packages/features/module-builder/src/components/module-form.tsx
Normal file
123
packages/features/module-builder/src/components/module-form.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FieldRenderer } from './field-renderer';
|
||||
|
||||
import type { CmsFieldType } from '../schema/module.schema';
|
||||
|
||||
interface FieldDefinition {
|
||||
name: string;
|
||||
display_name: string;
|
||||
field_type: CmsFieldType;
|
||||
is_required: boolean;
|
||||
placeholder?: string | null;
|
||||
help_text?: string | null;
|
||||
is_readonly: boolean;
|
||||
select_options?: Array<{ label: string; value: string }> | null;
|
||||
section: string;
|
||||
sort_order: number;
|
||||
show_in_form: boolean;
|
||||
width: string;
|
||||
}
|
||||
|
||||
interface ModuleFormProps {
|
||||
fields: FieldDefinition[];
|
||||
initialData?: Record<string, unknown>;
|
||||
onSubmit: (data: Record<string, unknown>) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
errors?: Array<{ field: string; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form component driven by module field definitions.
|
||||
* Replaces the legacy my_modulklasse form rendering.
|
||||
*/
|
||||
export function ModuleForm({
|
||||
fields,
|
||||
initialData = {},
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
errors = [],
|
||||
}: ModuleFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
|
||||
|
||||
const visibleFields = fields
|
||||
.filter((f) => f.show_in_form)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
// Group fields by section
|
||||
const sections = new Map<string, FieldDefinition[]>();
|
||||
for (const field of visibleFields) {
|
||||
const section = field.section || 'default';
|
||||
if (!sections.has(section)) {
|
||||
sections.set(section, []);
|
||||
}
|
||||
sections.get(section)!.push(field);
|
||||
}
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
const getFieldError = (fieldName: string) => {
|
||||
return errors.find((e) => e.field === fieldName)?.message;
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{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">
|
||||
{sectionName}
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sectionFields.map((field) => (
|
||||
<div key={field.name} className={getWidthClass(field.width)}>
|
||||
<FieldRenderer
|
||||
name={field.name}
|
||||
displayName={field.display_name}
|
||||
fieldType={field.field_type}
|
||||
value={formData[field.name]}
|
||||
onChange={(value) => handleFieldChange(field.name, value)}
|
||||
placeholder={field.placeholder ?? undefined}
|
||||
helpText={field.help_text ?? undefined}
|
||||
required={field.is_required}
|
||||
readonly={field.is_readonly}
|
||||
error={getFieldError(field.name)}
|
||||
selectOptions={field.select_options ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<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"
|
||||
>
|
||||
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Input } from '@kit/ui/input';
|
||||
|
||||
interface FilterValue {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FieldOption {
|
||||
name: string;
|
||||
display_name: string;
|
||||
show_in_filter: boolean;
|
||||
show_in_search: boolean;
|
||||
}
|
||||
|
||||
interface ModuleSearchProps {
|
||||
fields: FieldOption[];
|
||||
onSearch: (search: string) => void;
|
||||
onFilter: (filters: FilterValue[]) => void;
|
||||
initialSearch?: string;
|
||||
initialFilters?: FilterValue[];
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: 'eq', label: 'gleich' },
|
||||
{ value: 'neq', label: 'ungleich' },
|
||||
{ value: 'like', label: 'enthält' },
|
||||
{ value: 'gt', label: 'größer als' },
|
||||
{ value: 'gte', label: 'größer/gleich' },
|
||||
{ value: 'lt', label: 'kleiner als' },
|
||||
{ value: 'lte', label: 'kleiner/gleich' },
|
||||
{ value: 'is_null', label: 'ist leer' },
|
||||
{ value: 'not_null', label: 'ist nicht leer' },
|
||||
];
|
||||
|
||||
export function ModuleSearch({
|
||||
fields,
|
||||
onSearch,
|
||||
onFilter,
|
||||
initialSearch = '',
|
||||
initialFilters = [],
|
||||
}: ModuleSearchProps) {
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [showAdvanced, setShowAdvanced] = useState(initialFilters.length > 0);
|
||||
const [filters, setFilters] = useState<FilterValue[]>(initialFilters);
|
||||
|
||||
const filterableFields = fields.filter((f) => f.show_in_filter);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(search);
|
||||
};
|
||||
|
||||
const addFilter = () => {
|
||||
if (filterableFields.length === 0) return;
|
||||
setFilters([...filters, { field: filterableFields[0]!.name, operator: 'eq', value: '' }]);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
const next = filters.filter((_, i) => i !== index);
|
||||
setFilters(next);
|
||||
onFilter(next);
|
||||
};
|
||||
|
||||
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'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
{filterableFields.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
Filter {showAdvanced ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
{filters.map((filter, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<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"
|
||||
>
|
||||
{filterableFields.map((f) => (
|
||||
<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"
|
||||
>
|
||||
{OPERATORS.map((op) => (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFilter(i)}
|
||||
className="text-destructive hover:text-destructive/80 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFilter}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
+ Filter hinzufügen
|
||||
</button>
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyFilters}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Filter anwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
packages/features/module-builder/src/components/module-table.tsx
Normal file
193
packages/features/module-builder/src/components/module-table.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import type { CmsFieldType } from '../schema/module.schema';
|
||||
|
||||
interface FieldDefinition {
|
||||
name: string;
|
||||
display_name: string;
|
||||
field_type: CmsFieldType;
|
||||
show_in_table: boolean;
|
||||
is_sortable: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ModuleRecord {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ModuleTableProps {
|
||||
fields: FieldDefinition[];
|
||||
records: ModuleRecord[];
|
||||
pagination: Pagination;
|
||||
onPageChange: (page: number) => void;
|
||||
onSort: (field: string, direction: 'asc' | 'desc') => void;
|
||||
onRowClick?: (recordId: string) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onSelectionChange?: (ids: Set<string>) => void;
|
||||
currentSort?: { field: string; direction: 'asc' | 'desc' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic data table driven by module field definitions.
|
||||
* Replaces the legacy my_modulklasse table rendering.
|
||||
* Uses native table for now; Phase 3 enhancement will use @kit/ui/data-table.
|
||||
*/
|
||||
export function ModuleTable({
|
||||
fields,
|
||||
records,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onRowClick,
|
||||
selectedIds = new Set(),
|
||||
onSelectionChange,
|
||||
currentSort,
|
||||
}: ModuleTableProps) {
|
||||
const visibleFields = fields
|
||||
.filter((f) => f.show_in_table)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
const formatCellValue = (value: unknown, fieldType: CmsFieldType): string => {
|
||||
if (value == null || value === '') return '—';
|
||||
|
||||
switch (fieldType) {
|
||||
case 'checkbox':
|
||||
return value ? '✓' : '✗';
|
||||
case 'currency':
|
||||
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); }
|
||||
case 'password':
|
||||
return '••••••';
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (fieldName: string) => {
|
||||
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">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{onSelectionChange && (
|
||||
<th className="p-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
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)));
|
||||
} else {
|
||||
onSelectionChange(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{visibleFields.map((field) => (
|
||||
<th
|
||||
key={field.name}
|
||||
className="p-3 text-left font-medium cursor-pointer hover:bg-muted/80 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>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={visibleFields.length + (onSelectionChange ? 1 : 0)}
|
||||
className="p-8 text-center text-muted-foreground"
|
||||
>
|
||||
Keine Datensätze gefunden
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
className="border-b hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
onClick={() => onRowClick?.(record.id)}
|
||||
>
|
||||
{onSelectionChange && (
|
||||
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(record.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (e.target.checked) {
|
||||
next.add(record.id);
|
||||
} else {
|
||||
next.delete(record.id);
|
||||
}
|
||||
onSelectionChange(next);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{visibleFields.map((field) => (
|
||||
<td key={field.name} className="p-3">
|
||||
{formatCellValue(record.data[field.name], field.field_type)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
interface ModuleToolbarProps {
|
||||
moduleName: string;
|
||||
permissions: {
|
||||
canInsert: boolean;
|
||||
canDelete: boolean;
|
||||
canImport: boolean;
|
||||
canExport: boolean;
|
||||
canPrint: boolean;
|
||||
canLock: boolean;
|
||||
canBulkEdit: boolean;
|
||||
};
|
||||
features: {
|
||||
enableImport: boolean;
|
||||
enableExport: boolean;
|
||||
enablePrint: boolean;
|
||||
enableLock: boolean;
|
||||
enableBulkEdit: boolean;
|
||||
};
|
||||
selectedCount: number;
|
||||
onNew?: () => void;
|
||||
onDelete?: () => void;
|
||||
onImport?: () => void;
|
||||
onExport?: () => void;
|
||||
onPrint?: () => void;
|
||||
onLock?: () => void;
|
||||
onBulkEdit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission-gated toolbar for module operations.
|
||||
*/
|
||||
export function ModuleToolbar({
|
||||
permissions,
|
||||
features,
|
||||
selectedCount,
|
||||
onNew,
|
||||
onDelete,
|
||||
onImport,
|
||||
onExport,
|
||||
onPrint,
|
||||
onLock,
|
||||
}: ModuleToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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"
|
||||
>
|
||||
+ Neu
|
||||
</button>
|
||||
)}
|
||||
|
||||
{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"
|
||||
>
|
||||
Löschen ({selectedCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 && permissions.canLock && features.enableLock && (
|
||||
<button
|
||||
onClick={onLock}
|
||||
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
Sperren ({selectedCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{permissions.canImport && features.enableImport && (
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
|
||||
{permissions.canExport && features.enableExport && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
|
||||
{permissions.canPrint && features.enablePrint && (
|
||||
<button
|
||||
onClick={onPrint}
|
||||
className="rounded-md border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
Drucken
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CmsFieldTypeEnum } from './module.schema';
|
||||
|
||||
/**
|
||||
* Schema for creating / updating a module field.
|
||||
*/
|
||||
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',
|
||||
}),
|
||||
displayName: z.string().min(1).max(128),
|
||||
fieldType: CmsFieldTypeEnum,
|
||||
sqlType: z.string().max(64).default('text'),
|
||||
|
||||
// Constraints
|
||||
isRequired: z.boolean().default(false),
|
||||
isUnique: z.boolean().default(false),
|
||||
defaultValue: z.string().optional(),
|
||||
minValue: z.number().optional(),
|
||||
maxValue: z.number().optional(),
|
||||
maxLength: z.number().int().optional(),
|
||||
regexPattern: z.string().optional(),
|
||||
|
||||
// Layout
|
||||
sortOrder: z.number().int().default(0),
|
||||
width: z.enum(['full', 'half', 'third', 'quarter']).default('full'),
|
||||
section: z.string().default('default'),
|
||||
rowIndex: z.number().int().default(0),
|
||||
colIndex: z.number().int().default(0),
|
||||
placeholder: z.string().optional(),
|
||||
helpText: z.string().optional(),
|
||||
|
||||
// Visibility
|
||||
showInTable: z.boolean().default(true),
|
||||
showInForm: z.boolean().default(true),
|
||||
showInSearch: z.boolean().default(false),
|
||||
showInFilter: z.boolean().default(false),
|
||||
showInExport: z.boolean().default(true),
|
||||
showInPrint: z.boolean().default(true),
|
||||
|
||||
// Behavior
|
||||
isSortable: z.boolean().default(true),
|
||||
isReadonly: z.boolean().default(false),
|
||||
isEncrypted: z.boolean().default(false),
|
||||
isCopyable: z.boolean().default(true),
|
||||
validationFn: z.string().optional(),
|
||||
|
||||
// Lookup
|
||||
lookupModuleId: z.string().uuid().optional(),
|
||||
lookupDisplayField: z.string().optional(),
|
||||
lookupValueField: z.string().optional(),
|
||||
|
||||
// Select options
|
||||
selectOptions: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})).optional(),
|
||||
|
||||
// GDPR
|
||||
isPersonalData: z.boolean().default(false),
|
||||
gdprPurpose: z.string().optional(),
|
||||
|
||||
// File
|
||||
allowedMimeTypes: z.array(z.string()).optional(),
|
||||
maxFileSize: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type CreateFieldInput = z.infer<typeof CreateFieldSchema>;
|
||||
|
||||
export const UpdateFieldSchema = CreateFieldSchema.partial().extend({
|
||||
fieldId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type UpdateFieldInput = z.infer<typeof UpdateFieldSchema>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for CSV/Excel import configuration.
|
||||
*/
|
||||
export const ColumnMappingSchema = z.object({
|
||||
sourceColumn: z.string(),
|
||||
targetField: z.string(),
|
||||
transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_dmy', 'date_mdy']).default('none'),
|
||||
});
|
||||
|
||||
export type ColumnMapping = z.infer<typeof ColumnMappingSchema>;
|
||||
|
||||
export const ImportConfigSchema = z.object({
|
||||
moduleId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
mappings: z.array(ColumnMappingSchema).min(1),
|
||||
skipFirstRow: z.boolean().default(true),
|
||||
onDuplicate: z.enum(['skip', 'overwrite', 'error']).default('skip'),
|
||||
dryRun: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type ImportConfigInput = z.infer<typeof ImportConfigSchema>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for querying module records via the module_query() RPC.
|
||||
*/
|
||||
export const FilterSchema = z.object({
|
||||
field: z.string().min(1),
|
||||
operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'is_null', 'not_null']),
|
||||
value: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterInput = z.infer<typeof FilterSchema>;
|
||||
|
||||
export const ModuleQuerySchema = z.object({
|
||||
moduleId: z.string().uuid(),
|
||||
filters: z.array(FilterSchema).default([]),
|
||||
sortField: z.string().optional(),
|
||||
sortDirection: z.enum(['asc', 'desc']).default('asc'),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(5).max(200).default(25),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ModuleQueryInput = z.infer<typeof ModuleQuerySchema>;
|
||||
|
||||
export const PaginationSchema = z.object({
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
total: z.number(),
|
||||
totalPages: z.number(),
|
||||
});
|
||||
|
||||
export type PaginationResult = z.infer<typeof PaginationSchema>;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for creating / updating a module record.
|
||||
* The `data` field is a flexible JSONB object validated at runtime
|
||||
* against the module's field definitions.
|
||||
*/
|
||||
export const CreateRecordSchema = z.object({
|
||||
moduleId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export type CreateRecordInput = z.infer<typeof CreateRecordSchema>;
|
||||
|
||||
export const UpdateRecordSchema = z.object({
|
||||
recordId: z.string().uuid(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export type UpdateRecordInput = z.infer<typeof UpdateRecordSchema>;
|
||||
|
||||
export const DeleteRecordSchema = z.object({
|
||||
recordId: z.string().uuid(),
|
||||
hard: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type DeleteRecordInput = z.infer<typeof DeleteRecordSchema>;
|
||||
|
||||
export const LockRecordSchema = z.object({
|
||||
recordId: z.string().uuid(),
|
||||
lock: z.boolean(),
|
||||
});
|
||||
|
||||
export type LockRecordInput = z.infer<typeof LockRecordSchema>;
|
||||
56
packages/features/module-builder/src/schema/module.schema.ts
Normal file
56
packages/features/module-builder/src/schema/module.schema.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* All CMS field types supported by the module engine.
|
||||
* 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',
|
||||
]);
|
||||
|
||||
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 type CmsRecordStatus = z.infer<typeof CmsRecordStatusEnum>;
|
||||
|
||||
/**
|
||||
* Schema for creating / updating a module definition.
|
||||
*/
|
||||
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',
|
||||
}),
|
||||
displayName: z.string().min(1).max(128),
|
||||
description: z.string().max(1024).optional(),
|
||||
icon: z.string().max(64).default('table'),
|
||||
status: CmsModuleStatusEnum.default('active'),
|
||||
sortOrder: z.number().int().default(0),
|
||||
defaultSortField: z.string().optional(),
|
||||
defaultSortDirection: z.enum(['asc', 'desc']).default('asc'),
|
||||
defaultPageSize: z.number().int().min(5).max(200).default(25),
|
||||
enableSearch: z.boolean().default(true),
|
||||
enableFilter: z.boolean().default(true),
|
||||
enableExport: z.boolean().default(true),
|
||||
enableImport: z.boolean().default(false),
|
||||
enablePrint: z.boolean().default(true),
|
||||
enableCopy: z.boolean().default(false),
|
||||
enableBulkEdit: z.boolean().default(false),
|
||||
enableHistory: z.boolean().default(true),
|
||||
enableSoftDelete: z.boolean().default(true),
|
||||
enableLock: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateModuleInput = z.infer<typeof CreateModuleSchema>;
|
||||
|
||||
export const UpdateModuleSchema = CreateModuleSchema.partial().extend({
|
||||
moduleId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type UpdateModuleInput = z.infer<typeof UpdateModuleSchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
'use server';
|
||||
|
||||
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 { createModuleBuilderApi } from '../api';
|
||||
|
||||
export const createModule = authActionClient
|
||||
.inputSchema(CreateModuleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
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');
|
||||
|
||||
return { success: true, module };
|
||||
});
|
||||
|
||||
export const updateModule = authActionClient
|
||||
.inputSchema(UpdateModuleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
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');
|
||||
|
||||
return { success: true, module };
|
||||
});
|
||||
|
||||
export const deleteModule = authActionClient
|
||||
.inputSchema(UpdateModuleSchema.pick({ moduleId: true }))
|
||||
.action(async ({ parsedInput: { moduleId } }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
|
||||
logger.info({ name: 'modules.delete', moduleId }, 'Archiving module...');
|
||||
|
||||
await api.modules.deleteModule(moduleId);
|
||||
|
||||
logger.info({ name: 'modules.delete', moduleId }, 'Module archived');
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
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 { createModuleBuilderApi } from '../api';
|
||||
import { validateRecordData } from '../services/record-validation.service';
|
||||
|
||||
export const createRecord = authActionClient
|
||||
.inputSchema(CreateRecordSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// Get field definitions for validation
|
||||
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 validation = validateRecordData(
|
||||
input.data as Record<string, unknown>,
|
||||
fields as Parameters<typeof validateRecordData>[1],
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
return { success: false, errors: validation.errors };
|
||||
}
|
||||
|
||||
logger.info({ name: 'records.create', moduleId: input.moduleId }, 'Creating record...');
|
||||
|
||||
const record = await api.records.createRecord(input, userId);
|
||||
|
||||
// Write audit log
|
||||
await api.audit.log({
|
||||
accountId: input.accountId,
|
||||
userId,
|
||||
tableName: 'module_records',
|
||||
recordId: record.id,
|
||||
action: 'insert',
|
||||
newData: input.data as Record<string, unknown>,
|
||||
});
|
||||
|
||||
return { success: true, record };
|
||||
});
|
||||
|
||||
export const updateRecord = authActionClient
|
||||
.inputSchema(UpdateRecordSchema.extend({ accountId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// Get existing record for audit
|
||||
const existing = await api.records.getRecord(input.recordId);
|
||||
|
||||
logger.info({ name: 'records.update', recordId: input.recordId }, 'Updating record...');
|
||||
|
||||
const record = await api.records.updateRecord(input, userId);
|
||||
|
||||
// Write audit log
|
||||
await api.audit.log({
|
||||
accountId: input.accountId,
|
||||
userId,
|
||||
tableName: 'module_records',
|
||||
recordId: record.id,
|
||||
action: 'update',
|
||||
oldData: existing.data as Record<string, unknown>,
|
||||
newData: input.data as Record<string, unknown>,
|
||||
});
|
||||
|
||||
return { success: true, record };
|
||||
});
|
||||
|
||||
export const deleteRecord = authActionClient
|
||||
.inputSchema(DeleteRecordSchema.extend({ accountId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// 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...');
|
||||
|
||||
await api.records.deleteRecord(input);
|
||||
|
||||
// Write audit log
|
||||
await api.audit.log({
|
||||
accountId: input.accountId,
|
||||
userId,
|
||||
tableName: 'module_records',
|
||||
recordId: input.recordId,
|
||||
action: 'delete',
|
||||
oldData: existing.data as Record<string, unknown>,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const lockRecord = authActionClient
|
||||
.inputSchema(LockRecordSchema.extend({ accountId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createModuleBuilderApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const record = await api.records.lockRecord(input, userId);
|
||||
|
||||
await api.audit.log({
|
||||
accountId: input.accountId,
|
||||
userId,
|
||||
tableName: 'module_records',
|
||||
recordId: input.recordId,
|
||||
action: 'lock',
|
||||
newData: { locked: input.lock },
|
||||
});
|
||||
|
||||
return { success: true, record };
|
||||
});
|
||||
24
packages/features/module-builder/src/server/api.ts
Normal file
24
packages/features/module-builder/src/server/api.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
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.
|
||||
* Usage: const api = createModuleBuilderApi(client);
|
||||
*
|
||||
* Note: Uses untyped SupabaseClient until typegen includes new CMS tables.
|
||||
* After running `pnpm supabase:web:typegen`, change to SupabaseClient<Database>.
|
||||
*/
|
||||
export function createModuleBuilderApi(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
modules: createModuleDefinitionService(client),
|
||||
query: createModuleQueryService(client),
|
||||
records: createRecordCrudService(client),
|
||||
audit: createAuditService(client),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
type Json = Database['public']['Tables']['audit_log']['Insert']['old_data'];
|
||||
|
||||
export function createAuditService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async log(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
tableName: string;
|
||||
recordId: string;
|
||||
action: 'insert' | 'update' | 'delete' | 'lock';
|
||||
oldData?: Record<string, unknown> | null;
|
||||
newData?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const { error } = await client.from('audit_log').insert({
|
||||
account_id: params.accountId,
|
||||
user_id: params.userId,
|
||||
table_name: params.tableName,
|
||||
record_id: params.recordId,
|
||||
action: params.action,
|
||||
old_data: (params.oldData ?? null) as Json,
|
||||
new_data: (params.newData ?? null) as Json,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[audit] Failed to write audit log:', error.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
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>) {
|
||||
return {
|
||||
async listModules(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('modules')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.neq('status', 'archived')
|
||||
.order('sort_order');
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async getModule(moduleId: string) {
|
||||
const { data, error } = await client
|
||||
.from('modules')
|
||||
.select('*')
|
||||
.eq('id', moduleId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async getModuleWithFields(moduleId: string) {
|
||||
const { data, error } = await client
|
||||
.from('modules')
|
||||
.select('*, fields:module_fields(*)')
|
||||
.eq('id', moduleId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createModule(input: CreateModuleInput) {
|
||||
const { data, error } = await client
|
||||
.from('modules')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
display_name: input.displayName,
|
||||
description: input.description,
|
||||
icon: input.icon,
|
||||
status: input.status,
|
||||
sort_order: input.sortOrder,
|
||||
default_sort_field: input.defaultSortField,
|
||||
default_sort_direction: input.defaultSortDirection,
|
||||
default_page_size: input.defaultPageSize,
|
||||
enable_search: input.enableSearch,
|
||||
enable_filter: input.enableFilter,
|
||||
enable_export: input.enableExport,
|
||||
enable_import: input.enableImport,
|
||||
enable_print: input.enablePrint,
|
||||
enable_copy: input.enableCopy,
|
||||
enable_bulk_edit: input.enableBulkEdit,
|
||||
enable_history: input.enableHistory,
|
||||
enable_soft_delete: input.enableSoftDelete,
|
||||
enable_lock: input.enableLock,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
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.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;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('modules')
|
||||
.update(updateData)
|
||||
.eq('id', input.moduleId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteModule(moduleId: string) {
|
||||
const { error } = await client
|
||||
.from('modules')
|
||||
.update({ status: 'archived' })
|
||||
.eq('id', moduleId);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { ModuleQueryInput } from '../../schema/module-query.schema';
|
||||
|
||||
/**
|
||||
* Service for querying module records via the module_query() RPC.
|
||||
* Note: Uses untyped client for new CMS tables until typegen runs.
|
||||
*/
|
||||
export function createModuleQueryService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async query(input: ModuleQueryInput) {
|
||||
const { data, error } = await client.rpc('module_query', {
|
||||
p_module_id: input.moduleId,
|
||||
p_filters: JSON.stringify(input.filters),
|
||||
p_sort_field: input.sortField ?? undefined,
|
||||
p_sort_direction: input.sortDirection,
|
||||
p_page: input.page,
|
||||
p_page_size: input.pageSize,
|
||||
p_search: input.search ?? undefined,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// RPC returns jsonb — parse the result
|
||||
const result = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
|
||||
return {
|
||||
data: result.data ?? [],
|
||||
pagination: result.pagination ?? {
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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';
|
||||
|
||||
type Json = Database['public']['Tables']['module_records']['Insert']['data'];
|
||||
|
||||
export function createRecordCrudService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async getRecord(recordId: string) {
|
||||
const { data, error } = await client
|
||||
.from('module_records')
|
||||
.select('*')
|
||||
.eq('id', recordId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createRecord(input: CreateRecordInput, userId: string) {
|
||||
const { data, error } = await client
|
||||
.from('module_records')
|
||||
.insert({
|
||||
module_id: input.moduleId,
|
||||
account_id: input.accountId,
|
||||
data: input.data as unknown as Json,
|
||||
status: 'active',
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRecord(input: UpdateRecordInput, userId: string) {
|
||||
const existing = await this.getRecord(input.recordId);
|
||||
if (existing.status === 'locked') {
|
||||
throw new Error('Record is locked and cannot be edited');
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('module_records')
|
||||
.update({
|
||||
data: input.data as unknown as Json,
|
||||
updated_by: userId,
|
||||
})
|
||||
.eq('id', input.recordId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteRecord(input: DeleteRecordInput) {
|
||||
if (input.hard) {
|
||||
const { error } = await client
|
||||
.from('module_records')
|
||||
.delete()
|
||||
.eq('id', input.recordId);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await client
|
||||
.from('module_records')
|
||||
.update({ status: 'deleted' })
|
||||
.eq('id', input.recordId);
|
||||
if (error) throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async lockRecord(input: LockRecordInput, userId: string) {
|
||||
if (input.lock) {
|
||||
const { data, error } = await client
|
||||
.from('module_records')
|
||||
.update({
|
||||
status: 'locked',
|
||||
locked_by: userId,
|
||||
locked_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', input.recordId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} else {
|
||||
const { data, error } = await client
|
||||
.from('module_records')
|
||||
.update({
|
||||
status: 'active',
|
||||
locked_by: null,
|
||||
locked_at: null,
|
||||
})
|
||||
.eq('id', input.recordId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CmsFieldType } from '../../schema/module.schema';
|
||||
|
||||
/**
|
||||
* Maps CMS field types to Zod validators at runtime.
|
||||
* Replaces legacy check_* PHP functions from my_modulklasse.
|
||||
*
|
||||
* check_email -> z.string().email()
|
||||
* check_text -> z.string()
|
||||
* check_integer -> z.coerce.number().int()
|
||||
* check_date -> z.coerce.date()
|
||||
* check_iban -> custom IBAN validator (modulo 97)
|
||||
* etc.
|
||||
*/
|
||||
|
||||
const IBAN_REGEX = /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/;
|
||||
|
||||
function validateIban(value: string): boolean {
|
||||
const cleaned = value.replace(/\s/g, '').toUpperCase();
|
||||
if (!IBAN_REGEX.test(cleaned)) return false;
|
||||
|
||||
// Move first 4 chars to end
|
||||
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
|
||||
|
||||
// Convert letters to numbers (A=10, B=11, ...)
|
||||
let numStr = '';
|
||||
for (const char of rearranged) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code >= 65 && code <= 90) {
|
||||
numStr += (code - 55).toString();
|
||||
} else {
|
||||
numStr += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Modulo 97 check
|
||||
let remainder = 0;
|
||||
for (const digit of numStr) {
|
||||
remainder = (remainder * 10 + parseInt(digit, 10)) % 97;
|
||||
}
|
||||
|
||||
return remainder === 1;
|
||||
}
|
||||
|
||||
interface FieldDefinition {
|
||||
field_type: CmsFieldType;
|
||||
is_required: boolean;
|
||||
min_value?: number | null;
|
||||
max_value?: number | null;
|
||||
max_length?: number | null;
|
||||
regex_pattern?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Zod schema for a single field based on its definition.
|
||||
*/
|
||||
export function buildFieldValidator(field: FieldDefinition): z.ZodTypeAny {
|
||||
let schema: z.ZodTypeAny;
|
||||
|
||||
switch (field.field_type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'richtext':
|
||||
case 'password':
|
||||
case 'hidden':
|
||||
case 'color':
|
||||
schema = z.string();
|
||||
if (field.max_length) {
|
||||
schema = (schema as z.ZodString).max(field.max_length);
|
||||
}
|
||||
if (field.regex_pattern) {
|
||||
schema = (schema as z.ZodString).regex(new RegExp(field.regex_pattern));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
schema = z.string().email();
|
||||
break;
|
||||
|
||||
case 'phone':
|
||||
schema = z.string().regex(/^[+]?[\d\s\-()]+$/, 'Invalid phone number');
|
||||
break;
|
||||
|
||||
case 'url':
|
||||
schema = z.string().url();
|
||||
break;
|
||||
|
||||
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);
|
||||
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);
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
case 'time':
|
||||
schema = z.string().min(1);
|
||||
break;
|
||||
|
||||
case 'checkbox':
|
||||
schema = z.coerce.boolean();
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
case 'radio':
|
||||
schema = z.string();
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
schema = z.string(); // storage path
|
||||
break;
|
||||
|
||||
case 'iban':
|
||||
schema = z.string().refine(validateIban, { message: 'Invalid IBAN' });
|
||||
break;
|
||||
|
||||
case 'computed':
|
||||
schema = z.any(); // computed fields are not user-editable
|
||||
break;
|
||||
|
||||
default:
|
||||
schema = z.string();
|
||||
}
|
||||
|
||||
// Make optional if not required
|
||||
if (!field.is_required) {
|
||||
schema = schema.optional().or(z.literal(''));
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete Zod object schema from an array of field definitions.
|
||||
*/
|
||||
export function buildRecordValidator(fields: FieldDefinition[]): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldDef = field as FieldDefinition & { name: string };
|
||||
if ('name' in field) {
|
||||
shape[fieldDef.name] = buildFieldValidator(field);
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate record data against field definitions.
|
||||
* Returns { success: true, data } or { success: false, errors }.
|
||||
*/
|
||||
export function validateRecordData(
|
||||
data: Record<string, unknown>,
|
||||
fields: (FieldDefinition & { name: string })[],
|
||||
) {
|
||||
const schema = buildRecordValidator(fields);
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true as const, data: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
errors: result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
8
packages/features/module-builder/tsconfig.json
Normal file
8
packages/features/module-builder/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
34
packages/features/newsletter/package.json
Normal file
34
packages/features/newsletter/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/newsletter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/features/newsletter/src/components/index.ts
Normal file
1
packages/features/newsletter/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
32
packages/features/newsletter/src/schema/newsletter.schema.ts
Normal file
32
packages/features/newsletter/src/schema/newsletter.schema.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NewsletterStatusEnum = z.enum(['draft', 'scheduled', 'sending', 'sent', 'failed']);
|
||||
|
||||
export const CreateNewsletterSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
subject: z.string().min(1).max(256),
|
||||
bodyHtml: z.string().min(1),
|
||||
bodyText: z.string().optional(),
|
||||
scheduledAt: z.string().optional(),
|
||||
});
|
||||
export type CreateNewsletterInput = z.infer<typeof CreateNewsletterSchema>;
|
||||
|
||||
export const CreateTemplateSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
bodyHtml: z.string().min(1),
|
||||
bodyText: z.string().optional(),
|
||||
variables: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const SelectRecipientsSchema = z.object({
|
||||
newsletterId: z.string().uuid(),
|
||||
memberFilter: z.object({
|
||||
status: z.array(z.string()).optional(),
|
||||
duesCategoryId: z.string().uuid().optional(),
|
||||
hasEmail: z.boolean().default(true),
|
||||
}).optional(),
|
||||
manualEmails: z.array(z.string().email()).optional(),
|
||||
});
|
||||
158
packages/features/newsletter/src/server/api.ts
Normal file
158
packages/features/newsletter/src/server/api.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateNewsletterInput } from '../schema/newsletter.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Template variable substitution.
|
||||
* Replaces {{variable}} placeholders with actual values.
|
||||
*/
|
||||
function substituteVariables(template: string, variables: Record<string, string>): string {
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createNewsletterApi(client: SupabaseClient<Database>) {
|
||||
const db = client;
|
||||
|
||||
return {
|
||||
// --- Templates ---
|
||||
async listTemplates(accountId: string) {
|
||||
const { data, error } = await client.from('newsletter_templates').select('*')
|
||||
.eq('account_id', accountId).order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createTemplate(input: { accountId: string; name: string; subject: string; bodyHtml: string; bodyText?: string; variables?: string[] }) {
|
||||
const { data, error } = await client.from('newsletter_templates').insert({
|
||||
account_id: input.accountId, name: input.name, subject: input.subject,
|
||||
body_html: input.bodyHtml, body_text: input.bodyText,
|
||||
variables: input.variables ?? [],
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Newsletters ---
|
||||
async listNewsletters(accountId: string) {
|
||||
const { data, error } = await client.from('newsletters').select('*')
|
||||
.eq('account_id', accountId).order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createNewsletter(input: CreateNewsletterInput, userId: string) {
|
||||
const { data, error } = await client.from('newsletters').insert({
|
||||
account_id: input.accountId, template_id: input.templateId,
|
||||
subject: input.subject, body_html: input.bodyHtml, body_text: input.bodyText,
|
||||
status: input.scheduledAt ? 'scheduled' : 'draft',
|
||||
scheduled_at: input.scheduledAt, created_by: userId,
|
||||
}).select().single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async getNewsletter(newsletterId: string) {
|
||||
const { data, error } = await client.from('newsletters').select('*').eq('id', newsletterId).single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Recipients ---
|
||||
async addRecipientsFromMembers(newsletterId: string, accountId: string, filter?: { status?: string[]; hasEmail?: boolean }) {
|
||||
let query = client.from('members').select('id, first_name, last_name, email')
|
||||
.eq('account_id', accountId).not('email', 'is', null).neq('email', '');
|
||||
if (filter?.status && filter.status.length > 0) {
|
||||
query = query.in('status', filter.status as Database['public']['Enums']['membership_status'][]);
|
||||
}
|
||||
|
||||
const { data: members, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const recipients = (members ?? []).map((m: any) => ({
|
||||
newsletter_id: newsletterId,
|
||||
member_id: m.id,
|
||||
email: m.email,
|
||||
name: `${m.first_name} ${m.last_name}`,
|
||||
status: 'pending',
|
||||
}));
|
||||
|
||||
if (recipients.length > 0) {
|
||||
const { error: insertError } = await client.from('newsletter_recipients').insert(recipients);
|
||||
if (insertError) throw insertError;
|
||||
}
|
||||
|
||||
// Update newsletter total
|
||||
await client.from('newsletters').update({ total_recipients: recipients.length }).eq('id', newsletterId);
|
||||
|
||||
return recipients.length;
|
||||
},
|
||||
|
||||
async getRecipients(newsletterId: string) {
|
||||
const { data, error } = await client.from('newsletter_recipients').select('*')
|
||||
.eq('newsletter_id', newsletterId).order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatch newsletter — sends to all pending recipients.
|
||||
* Uses @kit/mailers under the hood. Rate-limited.
|
||||
* This is a preview implementation; actual dispatch needs the mailer service.
|
||||
*/
|
||||
async dispatch(newsletterId: string) {
|
||||
const newsletter = await this.getNewsletter(newsletterId);
|
||||
const recipients = await this.getRecipients(newsletterId);
|
||||
const pending = recipients.filter((r: any) => r.status === 'pending');
|
||||
|
||||
// Mark as sending
|
||||
await client.from('newsletters').update({ status: 'sending' }).eq('id', newsletterId);
|
||||
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const recipient of pending) {
|
||||
try {
|
||||
// Substitute variables in the body
|
||||
const personalizedHtml = substituteVariables(newsletter.body_html, {
|
||||
first_name: (recipient.name ?? '').split(' ')[0] ?? '',
|
||||
name: recipient.name ?? '',
|
||||
email: recipient.email ?? '',
|
||||
});
|
||||
|
||||
// TODO: Use @kit/mailers to actually send
|
||||
// await mailer.send({ to: recipient.email, subject: newsletter.subject, html: personalizedHtml });
|
||||
|
||||
await client.from('newsletter_recipients').update({
|
||||
status: 'sent', sent_at: new Date().toISOString(),
|
||||
}).eq('id', recipient.id);
|
||||
sentCount++;
|
||||
} catch (err) {
|
||||
await client.from('newsletter_recipients').update({
|
||||
status: 'failed', error_message: err instanceof Error ? err.message : 'Unknown error',
|
||||
}).eq('id', recipient.id);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update newsletter totals
|
||||
await client.from('newsletters').update({
|
||||
status: failedCount === pending.length ? 'failed' : 'sent',
|
||||
sent_at: new Date().toISOString(),
|
||||
sent_count: sentCount,
|
||||
failed_count: failedCount,
|
||||
}).eq('id', newsletterId);
|
||||
|
||||
return { sentCount, failedCount };
|
||||
},
|
||||
|
||||
// Utility
|
||||
substituteVariables,
|
||||
};
|
||||
}
|
||||
6
packages/features/newsletter/tsconfig.json
Normal file
6
packages/features/newsletter/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -8,9 +8,5 @@ import { defaultLocale } from './default-locale';
|
||||
*/
|
||||
export const locales: string[] = [
|
||||
defaultLocale,
|
||||
// Add other locales here as needed
|
||||
// Example: 'es', 'fr', 'de', etc.
|
||||
// Uncomment the locales below to enable them:
|
||||
// 'es', // Spanish
|
||||
// 'fr', // French
|
||||
'de', // German — primary locale for MyEasyCMS
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user