diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx index 7b12fb12e..9f8d7b177 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx @@ -45,40 +45,54 @@ export default async function MembersPage({ params, searchParams }: Props) { pageSize: PAGE_SIZE, }); - // Fetch categories, departments, and tags in parallel - const [duesCategories, departments, tagsResult, tagAssignmentsResult] = - await Promise.all([ - organization.listDuesCategories(acct.id), - organization.listDepartmentsWithCounts(acct.id), - (client.from as any)('member_tags') - .select('id, name, color') - .eq('account_id', acct.id) - .order('sort_order'), - (client.from as any)('member_tag_assignments') - .select('member_id, tag_id, member_tags(id, name, color)') - .in( - 'member_id', - result.data.map((m: any) => m.id), - ), - ]); + // Fetch categories and departments (always available) + const [duesCategories, departments] = await Promise.all([ + organization.listDuesCategories(acct.id), + organization.listDepartmentsWithCounts(acct.id), + ]); - // Build memberTags lookup: { memberId: [{ id, name, color }] } + // Fetch tags gracefully (tables may not exist if migration hasn't run) + let accountTags: Array<{ id: string; name: string; color: string }> = []; const memberTags: Record< string, Array<{ id: string; name: string; color: string }> > = {}; - for (const a of tagAssignmentsResult.data ?? []) { - const memberId = String(a.member_id); - const tag = a.member_tags; - if (!tag) continue; + try { + const memberIds = result.data.map((m: any) => m.id); - if (!memberTags[memberId]) memberTags[memberId] = []; - memberTags[memberId]!.push({ - id: String(tag.id), - name: String(tag.name), - color: String(tag.color), - }); + const [tagsResult, tagAssignmentsResult] = await Promise.all([ + (client.from as any)('member_tags') + .select('id, name, color') + .eq('account_id', acct.id) + .order('sort_order'), + memberIds.length > 0 + ? (client.from as any)('member_tag_assignments') + .select('member_id, tag_id, member_tags(id, name, color)') + .in('member_id', memberIds) + : { data: [] }, + ]); + + accountTags = (tagsResult.data ?? []).map((t: any) => ({ + id: String(t.id), + name: String(t.name), + color: String(t.color), + })); + + for (const a of tagAssignmentsResult.data ?? []) { + const memberId = String(a.member_id); + const tag = a.member_tags; + if (!tag) continue; + + if (!memberTags[memberId]) memberTags[memberId] = []; + memberTags[memberId]!.push({ + id: String(tag.id), + name: String(tag.name), + color: String(tag.color), + }); + } + } catch { + // Tags tables may not exist yet — gracefully degrade } return ( @@ -100,11 +114,7 @@ export default async function MembersPage({ params, searchParams }: Props) { name: String(d.name), memberCount: d.memberCount, }))} - tags={(tagsResult.data ?? []).map((t: any) => ({ - id: String(t.id), - name: String(t.name), - color: String(t.color), - }))} + tags={accountTags} memberTags={memberTags} /> ); diff --git a/apps/web/lib/database.types.ts b/apps/web/lib/database.types.ts index 1307ff791..d83bd525e 100644 --- a/apps/web/lib/database.types.ts +++ b/apps/web/lib/database.types.ts @@ -484,6 +484,68 @@ export type Database = { }, ] } + booking_audit_log: { + Row: { + account_id: string + action: string + booking_id: string + changes: Json + created_at: string + id: number + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + booking_id: string + changes?: Json + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + booking_id?: string + changes?: Json + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "booking_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "booking_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "booking_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "booking_audit_log_booking_id_fkey" + columns: ["booking_id"] + isOneToOne: false + referencedRelation: "bookings" + referencedColumns: ["id"] + }, + ] + } bookings: { Row: { account_id: string @@ -492,6 +554,7 @@ export type Database = { check_out: string children: number created_at: string + created_by: string | null extras: Json guest_id: string | null id: string @@ -500,6 +563,8 @@ export type Database = { status: string total_price: number updated_at: string + updated_by: string | null + version: number } Insert: { account_id: string @@ -508,6 +573,7 @@ export type Database = { check_out: string children?: number created_at?: string + created_by?: string | null extras?: Json guest_id?: string | null id?: string @@ -516,6 +582,8 @@ export type Database = { status?: string total_price?: number updated_at?: string + updated_by?: string | null + version?: number } Update: { account_id?: string @@ -524,6 +592,7 @@ export type Database = { check_out?: string children?: number created_at?: string + created_by?: string | null extras?: Json guest_id?: string | null id?: string @@ -532,6 +601,8 @@ export type Database = { status?: string total_price?: number updated_at?: string + updated_by?: string | null + version?: number } Relationships: [ { @@ -1566,6 +1637,68 @@ export type Database = { }, ] } + course_audit_log: { + Row: { + account_id: string + action: string + changes: Json + course_id: string + created_at: string + id: number + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + changes?: Json + course_id: string + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + changes?: Json + course_id?: string + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "course_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_audit_log_course_id_fkey" + columns: ["course_id"] + isOneToOne: false + referencedRelation: "courses" + referencedColumns: ["id"] + }, + ] + } course_categories: { Row: { account_id: string @@ -1859,6 +1992,7 @@ export type Database = { category_id: string | null course_number: string | null created_at: string + created_by: string | null custom_data: Json description: string | null end_date: string | null @@ -1874,6 +2008,8 @@ export type Database = { start_date: string | null status: string updated_at: string + updated_by: string | null + version: number } Insert: { account_id: string @@ -1881,6 +2017,7 @@ export type Database = { category_id?: string | null course_number?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -1896,6 +2033,8 @@ export type Database = { start_date?: string | null status?: string updated_at?: string + updated_by?: string | null + version?: number } Update: { account_id?: string @@ -1903,6 +2042,7 @@ export type Database = { category_id?: string | null course_number?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -1918,6 +2058,8 @@ export type Database = { start_date?: string | null status?: string updated_at?: string + updated_by?: string | null + version?: number } Relationships: [ { @@ -2104,6 +2246,68 @@ export type Database = { }, ] } + event_audit_log: { + Row: { + account_id: string + action: string + changes: Json + created_at: string + event_id: string + id: number + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + changes?: Json + created_at?: string + event_id: string + id?: never + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + changes?: Json + created_at?: string + event_id?: string + id?: never + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "event_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_audit_log_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, + ] + } event_registrations: { Row: { created_at: string @@ -2113,11 +2317,13 @@ export type Database = { first_name: string id: string last_name: string + member_id: string | null notes: string | null parent_name: string | null parent_phone: string | null phone: string | null status: string + updated_at: string | null } Insert: { created_at?: string @@ -2127,11 +2333,13 @@ export type Database = { first_name: string id?: string last_name: string + member_id?: string | null notes?: string | null parent_name?: string | null parent_phone?: string | null phone?: string | null status?: string + updated_at?: string | null } Update: { created_at?: string @@ -2141,11 +2349,13 @@ export type Database = { first_name?: string id?: string last_name?: string + member_id?: string | null notes?: string | null parent_name?: string | null parent_phone?: string | null phone?: string | null status?: string + updated_at?: string | null } Relationships: [ { @@ -2155,6 +2365,13 @@ export type Database = { referencedRelation: "events" referencedColumns: ["id"] }, + { + foreignKeyName: "event_registrations_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, ] } events: { @@ -2165,6 +2382,7 @@ export type Database = { contact_name: string | null contact_phone: string | null created_at: string + created_by: string | null custom_data: Json description: string | null end_date: string | null @@ -2180,6 +2398,8 @@ export type Database = { shared_with_hierarchy: boolean status: string updated_at: string + updated_by: string | null + version: number } Insert: { account_id: string @@ -2188,6 +2408,7 @@ export type Database = { contact_name?: string | null contact_phone?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -2203,6 +2424,8 @@ export type Database = { shared_with_hierarchy?: boolean status?: string updated_at?: string + updated_by?: string | null + version?: number } Update: { account_id?: string @@ -2211,6 +2434,7 @@ export type Database = { contact_name?: string | null contact_phone?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -2226,6 +2450,8 @@ export type Database = { shared_with_hierarchy?: boolean status?: string updated_at?: string + updated_by?: string | null + version?: number } Relationships: [ { @@ -2785,6 +3011,61 @@ export type Database = { }, ] } + gdpr_retention_policies: { + Row: { + account_id: string + applies_to_status: string[] + auto_anonymize: boolean + created_at: string + id: string + policy_name: string + retention_days: number + updated_at: string + } + Insert: { + account_id: string + applies_to_status?: string[] + auto_anonymize?: boolean + created_at?: string + id?: string + policy_name?: string + retention_days?: number + updated_at?: string + } + Update: { + account_id?: string + applies_to_status?: string[] + auto_anonymize?: boolean + created_at?: string + id?: string + policy_name?: string + retention_days?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "gdpr_retention_policies_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "gdpr_retention_policies_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "gdpr_retention_policies_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } guests: { Row: { account_id: string @@ -3425,6 +3706,68 @@ export type Database = { }, ] } + member_audit_log: { + Row: { + account_id: string + action: string + changes: Json + created_at: string + id: number + member_id: string + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + changes?: Json + created_at?: string + id?: never + member_id: string + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + changes?: Json + created_at?: string + id?: never + member_id?: string + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "member_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_audit_log_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } member_cards: { Row: { account_id: string @@ -3603,6 +3946,83 @@ export type Database = { }, ] } + member_communications: { + Row: { + account_id: string + attachment_paths: string[] | null + body: string | null + created_at: string + created_by: string + direction: string + email_cc: string | null + email_message_id: string | null + email_to: string | null + id: string + member_id: string + subject: string | null + type: string + } + Insert: { + account_id: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by: string + direction?: string + email_cc?: string | null + email_message_id?: string | null + email_to?: string | null + id?: string + member_id: string + subject?: string | null + type: string + } + Update: { + account_id?: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by?: string + direction?: string + email_cc?: string | null + email_message_id?: string | null + email_to?: string | null + id?: string + member_id?: string + subject?: string | null + type?: string + } + Relationships: [ + { + foreignKeyName: "member_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_communications_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } member_department_assignments: { Row: { department_id: string @@ -3741,6 +4161,125 @@ export type Database = { }, ] } + member_merges: { + Row: { + account_id: string + field_choices: Json + id: string + performed_at: string + performed_by: string + primary_member_id: string + references_moved: Json + secondary_member_id: string + secondary_snapshot: Json + } + Insert: { + account_id: string + field_choices: Json + id?: string + performed_at?: string + performed_by: string + primary_member_id: string + references_moved: Json + secondary_member_id: string + secondary_snapshot: Json + } + Update: { + account_id?: string + field_choices?: Json + id?: string + performed_at?: string + performed_by?: string + primary_member_id?: string + references_moved?: Json + secondary_member_id?: string + secondary_snapshot?: Json + } + Relationships: [ + { + foreignKeyName: "member_merges_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_merges_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_merges_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } + member_notification_rules: { + Row: { + account_id: string + channel: string + created_at: string + id: string + is_active: boolean + message_template: string + recipient_config: Json + recipient_type: string + subject_template: string | null + trigger_event: string + } + Insert: { + account_id: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template: string + recipient_config?: Json + recipient_type: string + subject_template?: string | null + trigger_event: string + } + Update: { + account_id?: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template?: string + recipient_config?: Json + recipient_type?: string + subject_template?: string | null + trigger_event?: string + } + Relationships: [ + { + foreignKeyName: "member_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } member_portal_invitations: { Row: { accepted_at: string | null @@ -3871,6 +4410,94 @@ export type Database = { }, ] } + member_tag_assignments: { + Row: { + assigned_at: string + assigned_by: string | null + member_id: string + tag_id: string + } + Insert: { + assigned_at?: string + assigned_by?: string | null + member_id: string + tag_id: string + } + Update: { + assigned_at?: string + assigned_by?: string | null + member_id?: string + tag_id?: string + } + Relationships: [ + { + foreignKeyName: "member_tag_assignments_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_tag_assignments_tag_id_fkey" + columns: ["tag_id"] + isOneToOne: false + referencedRelation: "member_tags" + referencedColumns: ["id"] + }, + ] + } + member_tags: { + Row: { + account_id: string + color: string + created_at: string + description: string | null + id: string + name: string + sort_order: number + } + Insert: { + account_id: string + color?: string + created_at?: string + description?: string | null + id?: string + name: string + sort_order?: number + } + Update: { + account_id?: string + color?: string + created_at?: string + description?: string | null + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "member_tags_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_tags_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_tags_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } member_transfers: { Row: { cleared_data: Json @@ -4013,6 +4640,7 @@ export type Database = { phone: string | null phone2: string | null postal_code: string | null + primary_mandate_id: string | null salutation: string | null sepa_bank_name: string | null sepa_mandate_date: string | null @@ -4029,6 +4657,7 @@ export type Database = { updated_at: string updated_by: string | null user_id: string | null + version: number } Insert: { account_holder?: string | null @@ -4088,6 +4717,7 @@ export type Database = { phone?: string | null phone2?: string | null postal_code?: string | null + primary_mandate_id?: string | null salutation?: string | null sepa_bank_name?: string | null sepa_mandate_date?: string | null @@ -4104,6 +4734,7 @@ export type Database = { updated_at?: string updated_by?: string | null user_id?: string | null + version?: number } Update: { account_holder?: string | null @@ -4163,6 +4794,7 @@ export type Database = { phone?: string | null phone2?: string | null postal_code?: string | null + primary_mandate_id?: string | null salutation?: string | null sepa_bank_name?: string | null sepa_mandate_date?: string | null @@ -4179,6 +4811,7 @@ export type Database = { updated_at?: string updated_by?: string | null user_id?: string | null + version?: number } Relationships: [ { @@ -4209,6 +4842,13 @@ export type Database = { referencedRelation: "user_accounts" referencedColumns: ["id"] }, + { + foreignKeyName: "members_primary_mandate_id_fkey" + columns: ["primary_mandate_id"] + isOneToOne: false + referencedRelation: "sepa_mandates" + referencedColumns: ["id"] + }, ] } membership_applications: { @@ -4303,6 +4943,76 @@ export type Database = { }, ] } + module_communications: { + Row: { + account_id: string + attachment_paths: string[] | null + body: string | null + created_at: string + created_by: string | null + direction: string + email_cc: string | null + email_to: string | null + entity_id: string + id: string + module: string + subject: string | null + type: string + } + Insert: { + account_id: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by?: string | null + direction?: string + email_cc?: string | null + email_to?: string | null + entity_id: string + id?: string + module: string + subject?: string | null + type?: string + } + Update: { + account_id?: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by?: string | null + direction?: string + email_cc?: string | null + email_to?: string | null + entity_id?: string + id?: string + module?: string + subject?: string | null + type?: string + } + Relationships: [ + { + foreignKeyName: "module_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } module_fields: { Row: { allowed_mime_types: string[] | null @@ -4450,6 +5160,70 @@ export type Database = { }, ] } + module_notification_rules: { + Row: { + account_id: string + channel: string + created_at: string + id: string + is_active: boolean + message_template: string + module: string + recipient_config: Json + recipient_type: string + subject_template: string | null + trigger_event: string + } + Insert: { + account_id: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template: string + module: string + recipient_config?: Json + recipient_type?: string + subject_template?: string | null + trigger_event: string + } + Update: { + account_id?: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template?: string + module?: string + recipient_config?: Json + recipient_type?: string + subject_template?: string | null + trigger_event?: string + } + Relationships: [ + { + foreignKeyName: "module_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } module_permissions: { Row: { can_bulk_edit: boolean @@ -5223,6 +5997,91 @@ export type Database = { }, ] } + pending_member_notifications: { + Row: { + account_id: string + context: Json + created_at: string + id: number + member_id: string | null + processed_at: string | null + trigger_event: string + } + Insert: { + account_id: string + context?: Json + created_at?: string + id?: never + member_id?: string | null + processed_at?: string | null + trigger_event: string + } + Update: { + account_id?: string + context?: Json + created_at?: string + id?: never + member_id?: string | null + processed_at?: string | null + trigger_event?: string + } + Relationships: [] + } + pending_module_notifications: { + Row: { + account_id: string + context: Json + created_at: string + entity_id: string + id: number + module: string + processed_at: string | null + trigger_event: string + } + Insert: { + account_id: string + context?: Json + created_at?: string + entity_id: string + id?: never + module: string + processed_at?: string | null + trigger_event: string + } + Update: { + account_id?: string + context?: Json + created_at?: string + entity_id?: string + id?: never + module?: string + processed_at?: string | null + trigger_event?: string + } + Relationships: [ + { + foreignKeyName: "pending_module_notifications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pending_module_notifications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pending_module_notifications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } permit_quotas: { Row: { business_year: number @@ -5369,6 +6228,96 @@ export type Database = { }, ] } + scheduled_job_configs: { + Row: { + account_id: string + config: Json + created_at: string + id: string + is_enabled: boolean + job_type: string + last_run_at: string | null + next_run_at: string | null + } + Insert: { + account_id: string + config?: Json + created_at?: string + id?: string + is_enabled?: boolean + job_type: string + last_run_at?: string | null + next_run_at?: string | null + } + Update: { + account_id?: string + config?: Json + created_at?: string + id?: string + is_enabled?: boolean + job_type?: string + last_run_at?: string | null + next_run_at?: string | null + } + Relationships: [ + { + foreignKeyName: "scheduled_job_configs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "scheduled_job_configs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "scheduled_job_configs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } + scheduled_job_runs: { + Row: { + completed_at: string | null + id: string + job_config_id: string + result: Json | null + started_at: string + status: string + } + Insert: { + completed_at?: string | null + id?: string + job_config_id: string + result?: Json | null + started_at?: string + status?: string + } + Update: { + completed_at?: string | null + id?: string + job_config_id?: string + result?: Json | null + started_at?: string + status?: string + } + Relationships: [ + { + foreignKeyName: "scheduled_job_runs_job_config_id_fkey" + columns: ["job_config_id"] + isOneToOne: false + referencedRelation: "scheduled_job_configs" + referencedColumns: ["id"] + }, + ] + } sepa_batches: { Row: { account_id: string @@ -6221,10 +7170,26 @@ export type Database = { } Returns: Database["public"]["Tables"]["invitations"]["Row"][] } + anonymize_member: { + Args: { p_member_id: string; p_performed_by?: string } + Returns: undefined + } + approve_application: { + Args: { p_application_id: string; p_user_id: string } + Returns: string + } can_action_account_member: { Args: { target_team_account_id: string; target_user_id: string } Returns: boolean } + cancel_course_enrollment: { + Args: { p_participant_id: string } + Returns: Json + } + cancel_event_registration: { + Args: { p_registration_id: string } + Returns: Json + } check_duplicate_member: { Args: { p_account_id: string @@ -6241,6 +7206,16 @@ export type Database = { status: Database["public"]["Enums"]["membership_status"] }[] } + check_instructor_availability: { + Args: { + p_end_time: string + p_exclude_session_id?: string + p_instructor_id: string + p_session_date: string + p_start_time: string + } + Returns: boolean + } clone_template: { Args: { p_new_name?: string @@ -6260,6 +7235,21 @@ export type Database = { } Returns: number } + create_booking_atomic: { + Args: { + p_account_id: string + p_adults?: number + p_check_in?: string + p_check_out?: string + p_children?: number + p_guest_id?: string + p_notes?: string + p_room_id: string + p_status?: string + p_total_price?: number + } + Returns: string + } create_invitation: { Args: { account_id: string; email: string; role: string } Returns: { @@ -6315,6 +7305,32 @@ export type Database = { isSetofReturn: false } } + delete_member_communication: { + Args: { p_account_id: string; p_communication_id: string } + Returns: undefined + } + enforce_gdpr_retention_policies: { Args: never; Returns: number } + enqueue_module_notification: { + Args: { + p_account_id: string + p_context?: Json + p_entity_id: string + p_module: string + p_trigger_event: string + } + Returns: undefined + } + enroll_course_participant: { + Args: { + p_course_id: string + p_email?: string + p_first_name?: string + p_last_name?: string + p_member_id?: string + p_phone?: string + } + Returns: Json + } get_account_ancestors: { Args: { child_id: string }; Returns: string[] } get_account_depth: { Args: { account_id: string }; Returns: number } get_account_descendants: { Args: { root_id: string }; Returns: string[] } @@ -6349,6 +7365,35 @@ export type Database = { user_id: string }[] } + get_booking_statistics: { + Args: { p_account_id: string; p_from?: string; p_to?: string } + Returns: { + active_bookings: number + avg_stay_nights: number + checked_in_count: number + occupancy_rate: number + total_bookings: number + total_revenue: number + }[] + } + get_booking_timeline: { + Args: { + p_action_filter?: string + p_booking_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_email: string + user_id: string + }[] + } get_catch_statistics: { Args: { p_account_id: string; p_water_id?: string; p_year?: number } Returns: { @@ -6372,6 +7417,102 @@ export type Database = { }[] } get_config: { Args: never; Returns: Json } + get_course_attendance_summary: { + Args: { p_course_id: string } + Returns: { + attendance_rate: number + enrollment_status: Database["public"]["Enums"]["enrollment_status"] + participant_id: string + participant_name: string + sessions_attended: number + total_sessions: number + }[] + } + get_course_statistics: { + Args: { p_account_id: string } + Returns: { + avg_occupancy_rate: number + cancelled_courses: number + completed_courses: number + open_courses: number + running_courses: number + total_courses: number + total_participants: number + total_revenue: number + total_waitlisted: number + }[] + } + get_course_timeline: { + Args: { + p_action_filter?: string + p_course_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_email: string + user_id: string + }[] + } + get_department_distribution: { + Args: { p_account_id: string } + Returns: { + department_name: string + member_count: number + percentage: number + }[] + } + get_dues_collection_report: { + Args: { p_account_id: string } + Returns: { + category_name: string + collection_rate: number + expected_amount: number + member_count: number + paid_count: number + }[] + } + get_event_registration_counts: { + Args: { p_event_ids: string[] } + Returns: { + event_id: string + registration_count: number + }[] + } + get_event_statistics: { + Args: { p_account_id: string } + Returns: { + avg_occupancy_rate: number + past_events: number + total_events: number + total_registrations: number + upcoming_events: number + }[] + } + get_event_timeline: { + Args: { + p_action_filter?: string + p_event_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_email: string + user_id: string + }[] + } get_hierarchy_report: { Args: { root_account_id: string } Returns: { @@ -6404,6 +7545,25 @@ export type Database = { total_upcoming_events: number }[] } + get_member_demographics: { + Args: { p_account_id: string } + Returns: { + age_group: string + diverse_count: number + female_count: number + male_count: number + total: number + unknown_count: number + }[] + } + get_member_geographic_distribution: { + Args: { p_account_id: string } + Returns: { + city: string + member_count: number + postal_prefix: string + }[] + } get_member_quick_stats: { Args: { p_account_id: string } Returns: { @@ -6416,6 +7576,43 @@ export type Database = { total: number }[] } + get_member_retention: { + Args: { p_account_id: string; p_years?: number } + Returns: { + members_end: number + members_start: number + new_members: number + resigned_members: number + retention_rate: number + year: number + }[] + } + get_member_timeline: { + Args: { + p_action_filter?: string + p_member_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_display_name: string + user_id: string + }[] + } + get_membership_duration_analysis: { + Args: { p_account_id: string } + Returns: { + duration_bucket: string + member_count: number + percentage: number + }[] + } get_next_member_number: { Args: { p_account_id: string } Returns: string @@ -6478,6 +7675,7 @@ export type Database = { } Returns: boolean } + install_extensions: { Args: never; Returns: undefined } is_aal2: { Args: never; Returns: boolean } is_account_owner: { Args: { account_id: string }; Returns: boolean } is_account_team_member: { @@ -6553,6 +7751,25 @@ export type Database = { template_type: string }[] } + log_member_audit_event: { + Args: { + p_account_id: string + p_action: string + p_changes?: Json + p_member_id: string + p_metadata?: Json + } + Returns: undefined + } + merge_members: { + Args: { + p_field_choices?: Json + p_performed_by?: string + p_primary_id: string + p_secondary_id: string + } + Returns: Json + } module_query: { Args: { p_filters?: Json @@ -6565,10 +7782,36 @@ export type Database = { } Returns: Json } + register_for_event: { + Args: { + p_date_of_birth?: string + p_email?: string + p_event_id: string + p_first_name?: string + p_last_name?: string + p_member_id?: string + p_parent_name?: string + p_parent_phone?: string + p_phone?: string + } + Returns: Json + } + reject_application: { + Args: { + p_application_id: string + p_review_notes?: string + p_user_id: string + } + Returns: undefined + } revoke_nonce: { Args: { p_id: string; p_reason?: string } Returns: boolean } + safe_delete_member: { + Args: { p_member_id: string; p_performed_by?: string } + Returns: undefined + } search_members_across_hierarchy: { Args: { account_filter?: string diff --git a/apps/web/supabase/migrations/20260416000004_member_constraints.sql b/apps/web/supabase/migrations/20260416000004_member_constraints.sql index 3f37a1484..9686d346f 100644 --- a/apps/web/supabase/migrations/20260416000004_member_constraints.sql +++ b/apps/web/supabase/migrations/20260416000004_member_constraints.sql @@ -17,7 +17,11 @@ UPDATE public.members SET exit_date = entry_date UPDATE public.members SET entry_date = current_date WHERE entry_date IS NOT NULL AND entry_date > current_date; --- Normalize IBANs in sepa_mandates to uppercase, strip spaces +-- Normalize IBANs to uppercase, strip spaces (both tables) +UPDATE public.members + SET iban = upper(regexp_replace(iban, '\s', '', 'g')) + WHERE iban IS NOT NULL AND iban != ''; + UPDATE public.sepa_mandates SET iban = upper(regexp_replace(iban, '\s', '', 'g')) WHERE iban IS NOT NULL AND iban != ''; diff --git a/apps/web/supabase/migrations/20260416000006_event_member_link.sql b/apps/web/supabase/migrations/20260416000006_event_member_link.sql index 4af55d589..f77c46ca5 100644 --- a/apps/web/supabase/migrations/20260416000006_event_member_link.sql +++ b/apps/web/supabase/migrations/20260416000006_event_member_link.sql @@ -21,12 +21,12 @@ CREATE INDEX IF NOT EXISTS ix_event_registrations_member -- Backfill: match existing registrations to members by email within the same account UPDATE public.event_registrations er SET member_id = m.id -FROM public.events e -JOIN public.members m ON m.account_id = e.account_id +FROM public.events e, public.members m +WHERE e.id = er.event_id + AND m.account_id = e.account_id AND lower(m.email) = lower(er.email) AND m.email IS NOT NULL AND m.email != '' AND m.status IN ('active', 'inactive', 'pending') -WHERE e.id = er.event_id AND er.member_id IS NULL AND er.email IS NOT NULL AND er.email != ''; @@ -35,7 +35,7 @@ CREATE OR REPLACE FUNCTION public.transfer_member( p_member_id uuid, p_target_account_id uuid, p_reason text DEFAULT NULL, - p_keep_sepa boolean DEFAULT false + p_keep_sepa boolean DEFAULT true ) RETURNS uuid LANGUAGE plpgsql diff --git a/apps/web/supabase/migrations/20260416999999_enable_btree_gist.sql b/apps/web/supabase/migrations/20260416999999_enable_btree_gist.sql new file mode 100644 index 000000000..2d738c1bb --- /dev/null +++ b/apps/web/supabase/migrations/20260416999999_enable_btree_gist.sql @@ -0,0 +1,3 @@ +-- Enable btree_gist extension (required by booking overlap exclusion constraint) +-- Separated into own migration to avoid "multiple commands in prepared statement" error +CREATE EXTENSION IF NOT EXISTS btree_gist; diff --git a/apps/web/supabase/migrations/20260417000003500_booking_atomic_function.sql b/apps/web/supabase/migrations/20260417000003500_booking_atomic_function.sql new file mode 100644 index 000000000..3229acd1d --- /dev/null +++ b/apps/web/supabase/migrations/20260417000003500_booking_atomic_function.sql @@ -0,0 +1,54 @@ +CREATE OR REPLACE FUNCTION public.create_booking_atomic( + p_account_id uuid, + p_room_id uuid, + p_guest_id uuid DEFAULT NULL, + p_check_in date DEFAULT NULL, + p_check_out date DEFAULT NULL, + p_adults integer DEFAULT 1, + p_children integer DEFAULT 0, + p_status text DEFAULT 'confirmed', + p_total_price numeric DEFAULT NULL, + p_notes text DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $fn$ +DECLARE + v_room record; + v_computed_price numeric(10,2); + v_booking_id uuid; +BEGIN + SELECT * INTO v_room FROM public.rooms WHERE id = p_room_id FOR UPDATE; + IF v_room IS NULL THEN + RAISE EXCEPTION 'Room % not found', p_room_id USING ERRCODE = 'P0002'; + END IF; + IF p_check_in IS NULL OR p_check_out IS NULL THEN + RAISE EXCEPTION 'check_in and check_out dates are required' USING ERRCODE = 'P0001'; + END IF; + IF p_check_out <= p_check_in THEN + RAISE EXCEPTION 'check_out must be after check_in' USING ERRCODE = 'P0001'; + END IF; + IF (p_adults + p_children) > v_room.capacity THEN + RAISE EXCEPTION 'Total guests exceed room capacity' USING ERRCODE = 'P0001'; + END IF; + + IF p_total_price IS NOT NULL THEN + v_computed_price := p_total_price; + ELSE + v_computed_price := v_room.price_per_night * (p_check_out - p_check_in); + END IF; + + INSERT INTO public.bookings ( + account_id, room_id, guest_id, check_in, check_out, + adults, children, status, total_price, notes + ) VALUES ( + p_account_id, p_room_id, p_guest_id, p_check_in, p_check_out, + p_adults, p_children, p_status, v_computed_price, p_notes + ) + RETURNING id INTO v_booking_id; + + RETURN v_booking_id; +END; +$fn$; diff --git a/apps/web/supabase/migrations/20260417000003600_booking_function_grants.sql b/apps/web/supabase/migrations/20260417000003600_booking_function_grants.sql new file mode 100644 index 000000000..8c623d25c --- /dev/null +++ b/apps/web/supabase/migrations/20260417000003600_booking_function_grants.sql @@ -0,0 +1 @@ +GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated; diff --git a/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql b/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql index 0e2cafd16..e8705aaca 100644 --- a/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql +++ b/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql @@ -1,25 +1,4 @@ --- ===================================================== --- Atomic Booking Creation with Overlap Prevention --- --- Problem: Creating a booking requires checking room --- availability, validating capacity, and inserting — all --- as separate queries. Race conditions can double-book --- a room for overlapping dates. --- --- Fix: --- A) Enable btree_gist extension for exclusion constraints. --- B) Add GiST exclusion constraint to prevent overlapping --- bookings for the same room (non-cancelled/no_show). --- C) Single transactional PG function that locks the room, --- validates inputs, calculates price, and inserts. The --- exclusion constraint provides a final safety net. --- ===================================================== - --- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types) -CREATE EXTENSION IF NOT EXISTS btree_gist; - --- B) Add exclusion constraint to prevent overlapping bookings (idempotent) -DO $$ +DO $excl$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates' @@ -32,97 +11,4 @@ BEGIN ) WHERE (status NOT IN ('cancelled', 'no_show')); END IF; END; -$$; - --- C) Atomic booking creation function -CREATE OR REPLACE FUNCTION public.create_booking_atomic( - p_account_id uuid, - p_room_id uuid, - p_guest_id uuid DEFAULT NULL, - p_check_in date DEFAULT NULL, - p_check_out date DEFAULT NULL, - p_adults integer DEFAULT 1, - p_children integer DEFAULT 0, - p_status text DEFAULT 'confirmed', - p_total_price numeric DEFAULT NULL, - p_notes text DEFAULT NULL -) -RETURNS uuid -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_room record; - v_computed_price numeric(10,2); - v_booking_id uuid; -BEGIN - -- 1. Lock the room row to serialize booking attempts - SELECT * INTO v_room - FROM public.rooms - WHERE id = p_room_id - FOR UPDATE; - - -- 2. Validate room exists - IF v_room IS NULL THEN - RAISE EXCEPTION 'Room % not found', p_room_id - USING ERRCODE = 'P0002'; - END IF; - - -- 3. Validate check_out > check_in - IF p_check_in IS NULL OR p_check_out IS NULL THEN - RAISE EXCEPTION 'check_in and check_out dates are required' - USING ERRCODE = 'P0001'; - END IF; - - IF p_check_out <= p_check_in THEN - RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in - USING ERRCODE = 'P0001'; - END IF; - - -- 4. Validate total guests do not exceed room capacity - IF (p_adults + p_children) > v_room.capacity THEN - RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity - USING ERRCODE = 'P0001'; - END IF; - - -- 5. Calculate price if not provided - IF p_total_price IS NOT NULL THEN - v_computed_price := p_total_price; - ELSE - v_computed_price := v_room.price_per_night * (p_check_out - p_check_in); - END IF; - - -- 6. Insert the booking (exclusion constraint prevents double-booking) - INSERT INTO public.bookings ( - account_id, - room_id, - guest_id, - check_in, - check_out, - adults, - children, - status, - total_price, - notes - ) VALUES ( - p_account_id, - p_room_id, - p_guest_id, - p_check_in, - p_check_out, - p_adults, - p_children, - p_status, - v_computed_price, - p_notes - ) - RETURNING id INTO v_booking_id; - - -- 7. Return the new booking id - RETURN v_booking_id; -END; -$$; - -GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated; -GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role; +$excl$; diff --git a/apps/web/supabase/migrations/20260418000006_notification_rules.sql b/apps/web/supabase/migrations/20260418000006_notification_rules.sql new file mode 100644 index 000000000..f450d7b43 --- /dev/null +++ b/apps/web/supabase/migrations/20260418000006_notification_rules.sql @@ -0,0 +1,93 @@ +-- ===================================================== +-- Module Notification Rules & Queue +-- Shared notification infrastructure for courses, events, bookings. +-- ===================================================== + +-- Notification rules: define what triggers notifications +CREATE TABLE IF NOT EXISTS public.module_notification_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')), + trigger_event text NOT NULL CHECK (trigger_event IN ( + 'course.participant_enrolled', 'course.participant_waitlisted', 'course.participant_promoted', + 'course.participant_cancelled', 'course.status_changed', 'course.session_reminder', + 'event.registration_confirmed', 'event.registration_waitlisted', 'event.registration_promoted', + 'event.registration_cancelled', 'event.status_changed', 'event.reminder', + 'booking.confirmed', 'booking.check_in_reminder', 'booking.checked_in', + 'booking.checked_out', 'booking.cancelled' + )), + channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')), + recipient_type text NOT NULL DEFAULT 'admin' CHECK (recipient_type IN ('admin', 'participant', 'guest', 'instructor', 'specific_user')), + recipient_config jsonb NOT NULL DEFAULT '{}', + subject_template text, + message_template text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_module_notification_rules_lookup + ON public.module_notification_rules(account_id, module, trigger_event) + WHERE is_active = true; + +ALTER TABLE public.module_notification_rules ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.module_notification_rules FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_notification_rules TO authenticated; +GRANT ALL ON public.module_notification_rules TO service_role; + +CREATE POLICY module_notification_rules_select ON public.module_notification_rules + FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); + +CREATE POLICY module_notification_rules_mutate ON public.module_notification_rules + FOR ALL TO authenticated USING ( + public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) + ) WITH CHECK ( + public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) + ); + +-- Pending notifications queue +CREATE TABLE IF NOT EXISTS public.pending_module_notifications ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')), + trigger_event text NOT NULL, + entity_id uuid NOT NULL, + context jsonb NOT NULL DEFAULT '{}', + processed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_pending_module_notifications_unprocessed + ON public.pending_module_notifications(created_at) + WHERE processed_at IS NULL; + +ALTER TABLE public.pending_module_notifications ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.pending_module_notifications FROM authenticated, service_role; +GRANT SELECT ON public.pending_module_notifications TO authenticated; +GRANT ALL ON public.pending_module_notifications TO service_role; + +CREATE POLICY pending_module_notifications_select ON public.pending_module_notifications + FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); + +-- Enqueue helper +CREATE OR REPLACE FUNCTION public.enqueue_module_notification( + p_account_id uuid, + p_module text, + p_trigger_event text, + p_entity_id uuid, + p_context jsonb DEFAULT '{}' +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.pending_module_notifications + (account_id, module, trigger_event, entity_id, context) + VALUES + (p_account_id, p_module, p_trigger_event, p_entity_id, p_context); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO service_role; diff --git a/apps/web/supabase/migrations/20260418000007_module_communications.sql b/apps/web/supabase/migrations/20260418000007_module_communications.sql new file mode 100644 index 000000000..f97ee6c6a --- /dev/null +++ b/apps/web/supabase/migrations/20260418000007_module_communications.sql @@ -0,0 +1,40 @@ +/* + * ------------------------------------------------------- + * Shared Communications Table for Courses, Events, Bookings + * Tracks email, phone, letter, meeting, note, sms entries + * ------------------------------------------------------- + */ + +CREATE TABLE IF NOT EXISTS public.module_communications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')), + entity_id uuid NOT NULL, + type text NOT NULL DEFAULT 'note' CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')), + direction text NOT NULL DEFAULT 'internal' CHECK (direction IN ('inbound', 'outbound', 'internal')), + subject text, + body text, + email_to text, + email_cc text, + attachment_paths text[], + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_module_communications_entity + ON public.module_communications(module, entity_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_module_communications_account + ON public.module_communications(account_id, module, created_at DESC); + +ALTER TABLE public.module_communications ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.module_communications FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_communications TO authenticated; +GRANT ALL ON public.module_communications TO service_role; + +CREATE POLICY module_communications_select ON public.module_communications + FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); + +CREATE POLICY module_communications_mutate ON public.module_communications + FOR ALL TO authenticated USING (public.has_role_on_account(account_id)) + WITH CHECK (public.has_role_on_account(account_id)); diff --git a/apps/web/supabase/tests/database/member-audit.test.sql b/apps/web/supabase/tests/database/member-audit.test.sql new file mode 100644 index 000000000..0586329e1 --- /dev/null +++ b/apps/web/supabase/tests/database/member-audit.test.sql @@ -0,0 +1,121 @@ +begin; + +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select no_plan(); + +-- ===================================================== +-- Audit Trigger & Version Tests +-- Verifies triggers fire correctly on member changes +-- ===================================================== + +-- Setup +select tests.create_supabase_user('audit_owner', 'audit_owner@test.com'); +select makerkit.set_identifier('audit_owner', 'audit_owner@test.com'); + +set local role service_role; +select public.create_team_account('Audit Verein', tests.get_supabase_uid('audit_owner')); + +set local role postgres; +insert into public.role_permissions (role, permission) +values ('owner', 'members.write') +on conflict do nothing; + +-- Get account ID +select makerkit.authenticate_as('audit_owner'); + +-- Insert a member (triggers audit INSERT) +set local role service_role; +insert into public.members ( + account_id, first_name, last_name, status, entry_date, member_number, + created_by, updated_by +) values ( + (select id from public.accounts where slug = 'audit-verein' limit 1), + 'Audit', 'Test', 'active', current_date, '0001', + tests.get_supabase_uid('audit_owner'), + tests.get_supabase_uid('audit_owner') +); + +-- ------------------------------------------------------- +-- Test: INSERT creates audit entry +-- ------------------------------------------------------- +select isnt_empty( + $$ select * from public.member_audit_log + where member_id = (select id from public.members where first_name = 'Audit' limit 1) + and action = 'created' $$, + 'Member INSERT creates audit entry with action=created' +); + +-- ------------------------------------------------------- +-- Test: Version starts at 1 +-- ------------------------------------------------------- +select is( + (select version from public.members where first_name = 'Audit' limit 1), + 1, + 'Initial version is 1' +); + +-- ------------------------------------------------------- +-- Test: UPDATE increments version +-- ------------------------------------------------------- +update public.members +set first_name = 'AuditUpdated' +where first_name = 'Audit'; + +select is( + (select version from public.members where first_name = 'AuditUpdated' limit 1), + 2, + 'Version incremented to 2 after update' +); + +-- ------------------------------------------------------- +-- Test: UPDATE creates audit entry with field diff +-- ------------------------------------------------------- +select isnt_empty( + $$ select * from public.member_audit_log + where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1) + and action = 'updated' + and changes ? 'first_name' $$, + 'Member UPDATE creates audit entry with first_name change diff' +); + +-- ------------------------------------------------------- +-- Test: Status change creates status_changed audit entry +-- ------------------------------------------------------- +update public.members +set status = 'inactive' +where first_name = 'AuditUpdated'; + +select isnt_empty( + $$ select * from public.member_audit_log + where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1) + and action = 'status_changed' $$, + 'Status change creates audit entry with action=status_changed' +); + +-- ------------------------------------------------------- +-- Test: Archive creates archived audit entry +-- ------------------------------------------------------- +update public.members +set is_archived = true +where first_name = 'AuditUpdated'; + +select isnt_empty( + $$ select * from public.member_audit_log + where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1) + and action = 'archived' $$, + 'Archive creates audit entry with action=archived' +); + +-- ------------------------------------------------------- +-- Test: Multiple updates increment version correctly +-- ------------------------------------------------------- +select is( + (select version from public.members where first_name = 'AuditUpdated' limit 1), + 4, + 'Version is 4 after 3 updates (initial insert + 3 updates)' +); + +select * from finish(); + +rollback; diff --git a/apps/web/supabase/tests/database/member-constraints.test.sql b/apps/web/supabase/tests/database/member-constraints.test.sql new file mode 100644 index 000000000..eb9f28ace --- /dev/null +++ b/apps/web/supabase/tests/database/member-constraints.test.sql @@ -0,0 +1,186 @@ +begin; + +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select no_plan(); + +-- ===================================================== +-- CHECK Constraint Tests +-- ===================================================== + +-- Setup +select tests.create_supabase_user('constraint_owner', 'constraint_owner@test.com'); +select makerkit.set_identifier('constraint_owner', 'constraint_owner@test.com'); + +set local role service_role; +select public.create_team_account('Constraint Verein', tests.get_supabase_uid('constraint_owner')); + +set local role postgres; +insert into public.role_permissions (role, permission) +values ('owner', 'members.write') +on conflict do nothing; + +-- ------------------------------------------------------- +-- Test: DOB in future rejected +-- ------------------------------------------------------- +set local role service_role; + +select throws_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, date_of_birth, status, entry_date, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'Future', 'Baby', current_date + interval '1 day', 'active', current_date, + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'new row for relation "members" violates check constraint "chk_members_dob_not_future"', + 'Future date of birth is rejected' +); + +-- ------------------------------------------------------- +-- Test: Exit date before entry date rejected +-- ------------------------------------------------------- +select throws_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, status, entry_date, exit_date, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'Wrong', 'Dates', 'resigned', '2024-06-01', '2024-01-01', + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'new row for relation "members" violates check constraint "chk_members_exit_after_entry"', + 'Exit date before entry date is rejected' +); + +-- ------------------------------------------------------- +-- Test: Entry date in future rejected +-- ------------------------------------------------------- +select throws_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, status, entry_date, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'Future', 'Entry', 'active', current_date + interval '2 days', + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'new row for relation "members" violates check constraint "chk_members_entry_not_future"', + 'Future entry date is rejected' +); + +-- ------------------------------------------------------- +-- Test: Valid member insert succeeds +-- ------------------------------------------------------- +select lives_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, status, entry_date, + date_of_birth, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'Valid', 'Member', 'active', '2024-01-15', '1990-05-20', + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'Valid member with correct dates succeeds' +); + +-- ------------------------------------------------------- +-- Test: Duplicate email in same account rejected +-- ------------------------------------------------------- +insert into public.members ( + account_id, first_name, last_name, email, status, entry_date, created_by, updated_by +) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'First', 'Email', 'duplicate@test.com', 'active', current_date, + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') +); + +select throws_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, email, status, entry_date, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'Second', 'Email', 'duplicate@test.com', 'active', current_date, + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'duplicate key value violates unique constraint "uix_members_email_per_account"', + 'Duplicate email in same account is rejected' +); + +-- ------------------------------------------------------- +-- Test: NULL emails allowed (multiple) +-- ------------------------------------------------------- +select lives_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, email, status, entry_date, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'No', 'Email1', null, 'active', current_date, + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'NULL email is allowed' +); + +select lives_ok( + $test$ insert into public.members ( + account_id, first_name, last_name, email, status, entry_date, created_by, updated_by + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'No', 'Email2', null, 'active', current_date, + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') + ) $test$, + 'Multiple NULL emails allowed' +); + +-- ------------------------------------------------------- +-- Test: Invalid IBAN rejected on sepa_mandates +-- ------------------------------------------------------- +insert into public.members ( + account_id, first_name, last_name, status, entry_date, member_number, created_by, updated_by +) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'SEPA', 'Test', 'active', current_date, 'SEPA01', + tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner') +); + +select throws_ok( + $test$ insert into public.sepa_mandates ( + member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status + ) values ( + (select id from public.members where first_name = 'SEPA' limit 1), + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'MANDATE-001', 'invalid-iban', 'Test Holder', current_date, 'active' + ) $test$, + 'new row for relation "sepa_mandates" violates check constraint "chk_sepa_iban_format"', + 'Invalid IBAN format is rejected' +); + +-- ------------------------------------------------------- +-- Test: Valid IBAN accepted +-- ------------------------------------------------------- +select lives_ok( + $test$ insert into public.sepa_mandates ( + member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status + ) values ( + (select id from public.members where first_name = 'SEPA' limit 1), + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'MANDATE-002', 'DE89370400440532013000', 'Test Holder', current_date, 'active' + ) $test$, + 'Valid German IBAN is accepted' +); + +-- ------------------------------------------------------- +-- Test: Negative dues amount rejected +-- ------------------------------------------------------- +select throws_ok( + $test$ insert into public.dues_categories ( + account_id, name, amount + ) values ( + (select id from public.accounts where slug = 'constraint-verein' limit 1), + 'Negative Fee', -50 + ) $test$, + 'new row for relation "dues_categories" violates check constraint "chk_dues_amount_non_negative"', + 'Negative dues amount is rejected' +); + +select * from finish(); + +rollback; diff --git a/apps/web/supabase/tests/database/member-functions.test.sql b/apps/web/supabase/tests/database/member-functions.test.sql new file mode 100644 index 000000000..f8faa31d7 --- /dev/null +++ b/apps/web/supabase/tests/database/member-functions.test.sql @@ -0,0 +1,211 @@ +begin; + +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select no_plan(); + +-- ===================================================== +-- Member Management Function Tests +-- Tests PG functions for correctness, auth, atomicity +-- ===================================================== + +-- Setup: create test users and account +select tests.create_supabase_user('owner', 'owner@test.com'); +select tests.create_supabase_user('member_user', 'member@test.com'); +select tests.create_supabase_user('outsider', 'outsider@test.com'); + +select makerkit.set_identifier('owner', 'owner@test.com'); +select makerkit.set_identifier('member_user', 'member@test.com'); +select makerkit.set_identifier('outsider', 'outsider@test.com'); + +-- Create a team account owned by 'owner' +set local role service_role; +select public.create_team_account('Test Verein', tests.get_supabase_uid('owner')); + +-- Get account ID +select makerkit.authenticate_as('owner'); +\set test_account_id '(select id from public.accounts where slug = ''test-verein'' limit 1)' + +-- Grant members.write permission to owner +set local role postgres; +insert into public.role_permissions (role, permission) +values ('owner', 'members.write') +on conflict do nothing; + +-- ------------------------------------------------------- +-- Test: get_next_member_number +-- ------------------------------------------------------- +select makerkit.authenticate_as('owner'); + +select is( + public.get_next_member_number(:test_account_id), + '0001', + 'First member number should be 0001' +); + +-- Insert a member to test incrementing +set local role service_role; +insert into public.members (account_id, first_name, last_name, member_number, status, entry_date, created_by, updated_by) +values (:test_account_id, 'Max', 'Mustermann', '0001', 'active', current_date, + tests.get_supabase_uid('owner'), tests.get_supabase_uid('owner')); + +select makerkit.authenticate_as('owner'); + +select is( + public.get_next_member_number(:test_account_id), + '0002', + 'Second member number should be 0002' +); + +-- ------------------------------------------------------- +-- Test: get_member_quick_stats +-- ------------------------------------------------------- +select isnt_empty( + $$ select * from public.get_member_quick_stats((select id from public.accounts where slug = 'test-verein' limit 1)) $$, + 'Quick stats returns data for account with members' +); + +-- ------------------------------------------------------- +-- Test: check_duplicate_member +-- ------------------------------------------------------- +select isnt_empty( + $$ select * from public.check_duplicate_member( + (select id from public.accounts where slug = 'test-verein' limit 1), + 'Max', 'Mustermann', null + ) $$, + 'Duplicate check finds existing member by name' +); + +select is_empty( + $$ select * from public.check_duplicate_member( + (select id from public.accounts where slug = 'test-verein' limit 1), + 'Nonexistent', 'Person', null + ) $$, + 'Duplicate check returns empty for non-matching name' +); + +-- ------------------------------------------------------- +-- Test: approve_application +-- ------------------------------------------------------- + +-- Create a test application +set local role service_role; +insert into public.membership_applications ( + account_id, first_name, last_name, email, status +) values ( + :test_account_id, 'Anna', 'Bewerberin', 'anna@test.com', 'submitted' +); + +select makerkit.authenticate_as('owner'); + +-- Approve it +select lives_ok( + $$ select public.approve_application( + (select id from public.membership_applications where email = 'anna@test.com'), + tests.get_supabase_uid('owner') + ) $$, + 'Owner can approve application' +); + +-- Verify member was created +select isnt_empty( + $$ select * from public.members where first_name = 'Anna' and last_name = 'Bewerberin' $$, + 'Approved application creates a member' +); + +-- Verify application status changed +select is( + (select status from public.membership_applications where email = 'anna@test.com'), + 'approved'::public.application_status, + 'Application status is approved' +); + +-- ------------------------------------------------------- +-- Test: reject_application +-- ------------------------------------------------------- +set local role service_role; +insert into public.membership_applications ( + account_id, first_name, last_name, email, status +) values ( + :test_account_id, 'Bob', 'Abgelehnt', 'bob@test.com', 'submitted' +); + +select makerkit.authenticate_as('owner'); + +select lives_ok( + $$ select public.reject_application( + (select id from public.membership_applications where email = 'bob@test.com'), + tests.get_supabase_uid('owner'), + 'Nicht qualifiziert' + ) $$, + 'Owner can reject application' +); + +select is( + (select status from public.membership_applications where email = 'bob@test.com'), + 'rejected'::public.application_status, + 'Application status is rejected' +); + +-- ------------------------------------------------------- +-- Test: approve_application — already approved should fail +-- ------------------------------------------------------- +-- Verify the re-approval throws with status message +prepare approve_again as select public.approve_application( + (select id from public.membership_applications where email = 'anna@test.com'), + tests.get_supabase_uid('owner') +); +select throws_ok( + 'approve_again', + 'P0001', + 'Application is not in a reviewable state (current: approved)', + 'Cannot approve already-approved application' +); + +-- ------------------------------------------------------- +-- Test: get_member_timeline +-- ------------------------------------------------------- +-- The member creation via approve_application should have generated an audit entry +select isnt_empty( + $$ select * from public.get_member_timeline( + (select id from public.members where first_name = 'Anna' limit 1), + 1, 50, null + ) $$, + 'Member timeline has entries after creation' +); + +-- ------------------------------------------------------- +-- Test: log_member_audit_event +-- ------------------------------------------------------- +select makerkit.authenticate_as('owner'); + +select lives_ok( + $$ select public.log_member_audit_event( + (select id from public.members where first_name = 'Max' limit 1), + (select id from public.accounts where slug = 'test-verein' limit 1), + 'note_added', + '{"note": "Test note"}'::jsonb, + '{}'::jsonb + ) $$, + 'Owner can log audit event for member' +); + +-- ------------------------------------------------------- +-- Test: outsider cannot access functions +-- ------------------------------------------------------- +select makerkit.authenticate_as('outsider'); + +-- Outsider should get an error when calling get_next_member_number +prepare outsider_member_number as select public.get_next_member_number( + (select id from public.accounts where slug = 'test-verein' limit 1) +); +select throws_ok( + 'outsider_member_number', + 'P0001', + null, + 'Outsider cannot call get_next_member_number' +); + +select * from finish(); + +rollback; diff --git a/apps/web/supabase/tests/database/member-tables.test.sql b/apps/web/supabase/tests/database/member-tables.test.sql new file mode 100644 index 000000000..1925ad947 --- /dev/null +++ b/apps/web/supabase/tests/database/member-tables.test.sql @@ -0,0 +1,105 @@ +begin; + +create extension "basejump-supabase_test_helpers" version '0.0.6'; + +select no_plan(); + +-- ===================================================== +-- Member Management Schema Tests +-- Verifies all tables, columns, and RLS settings +-- ===================================================== + +-- 1. Core tables exist +select has_table('public', 'members', 'members table exists'); +select has_table('public', 'dues_categories', 'dues_categories table exists'); +select has_table('public', 'membership_applications', 'membership_applications table exists'); +select has_table('public', 'member_cards', 'member_cards table exists'); +select has_table('public', 'member_departments', 'member_departments table exists'); +select has_table('public', 'member_department_assignments', 'member_department_assignments table exists'); +select has_table('public', 'member_roles', 'member_roles table exists'); +select has_table('public', 'member_honors', 'member_honors table exists'); +select has_table('public', 'sepa_mandates', 'sepa_mandates table exists'); +select has_table('public', 'member_portal_invitations', 'member_portal_invitations table exists'); +select has_table('public', 'member_transfers', 'member_transfers table exists'); + +-- 2. New Phase 1-4 tables exist +select has_table('public', 'member_audit_log', 'member_audit_log table exists'); +select has_table('public', 'member_communications', 'member_communications table exists'); +select has_table('public', 'member_tags', 'member_tags table exists'); +select has_table('public', 'member_tag_assignments', 'member_tag_assignments table exists'); +select has_table('public', 'member_merges', 'member_merges table exists'); +select has_table('public', 'gdpr_retention_policies', 'gdpr_retention_policies table exists'); +select has_table('public', 'member_notification_rules', 'member_notification_rules table exists'); +select has_table('public', 'scheduled_job_configs', 'scheduled_job_configs table exists'); +select has_table('public', 'scheduled_job_runs', 'scheduled_job_runs table exists'); +select has_table('public', 'pending_member_notifications', 'pending_member_notifications table exists'); + +-- 3. New columns on members table +select has_column('public', 'members', 'primary_mandate_id', 'members has primary_mandate_id column'); +select has_column('public', 'members', 'version', 'members has version column'); + +-- 4. New column on event_registrations +select has_column('public', 'event_registrations', 'member_id', 'event_registrations has member_id FK'); + +-- 5. RLS enabled on all member tables +select is( + (select relrowsecurity from pg_class where relname = 'members' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on members' +); +select is( + (select relrowsecurity from pg_class where relname = 'member_audit_log' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on member_audit_log' +); +select is( + (select relrowsecurity from pg_class where relname = 'member_communications' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on member_communications' +); +select is( + (select relrowsecurity from pg_class where relname = 'member_tags' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on member_tags' +); +select is( + (select relrowsecurity from pg_class where relname = 'member_tag_assignments' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on member_tag_assignments' +); +select is( + (select relrowsecurity from pg_class where relname = 'member_notification_rules' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on member_notification_rules' +); +select is( + (select relrowsecurity from pg_class where relname = 'scheduled_job_configs' and relnamespace = 'public'::regnamespace), + true, 'RLS enabled on scheduled_job_configs' +); + +-- 6. Key indexes exist +select is( + (select count(*) > 0 from pg_indexes where tablename = 'members' and indexname = 'ix_members_active_account_status'), + true, 'Active members composite index exists' +); +select is( + (select count(*) > 0 from pg_indexes where tablename = 'member_audit_log' and indexname = 'ix_member_audit_member'), + true, 'Audit log member index exists' +); + +-- 7. Check constraints exist on members +select is( + (select count(*) > 0 from information_schema.check_constraints + where constraint_name = 'chk_members_dob_not_future'), + true, 'DOB not-future constraint exists' +); +select is( + (select count(*) > 0 from information_schema.check_constraints + where constraint_name = 'chk_members_exit_after_entry'), + true, 'Exit-after-entry constraint exists' +); + +-- 8. Version column has correct default +select is( + (select column_default from information_schema.columns + where table_name = 'members' and column_name = 'version'), + '1', 'Version column defaults to 1' +); + +select * from finish(); + +rollback; diff --git a/packages/features/booking-management/src/server/services/booking-communication.service.ts b/packages/features/booking-management/src/server/services/booking-communication.service.ts new file mode 100644 index 000000000..06fd31d1a --- /dev/null +++ b/packages/features/booking-management/src/server/services/booking-communication.service.ts @@ -0,0 +1,99 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +interface CommunicationListOptions { + type?: string; + direction?: string; + search?: string; + page?: number; + pageSize?: number; +} + +interface CreateCommunicationInput { + accountId: string; + entityId: string; + type: string; + direction?: string; + subject?: string; + body?: string; + emailTo?: string; + emailCc?: string; + attachmentPaths?: string[]; +} + +export function createBookingCommunicationService( + client: SupabaseClient, +) { + return new BookingCommunicationService(client); +} + +class BookingCommunicationService { + constructor(private readonly client: SupabaseClient) {} + + async list( + bookingId: string, + accountId: string, + opts?: CommunicationListOptions, + ) { + let query = (this.client.from as CallableFunction)('module_communications') + .select('*', { count: 'exact' }) + .eq('module', 'bookings') + .eq('entity_id', bookingId) + .eq('account_id', accountId) + .order('created_at', { ascending: false }); + + if (opts?.type) query = query.eq('type', opts.type); + if (opts?.direction) query = query.eq('direction', opts.direction); + if (opts?.search) { + const escaped = opts.search.replace(/[%_\\]/g, '\\$&'); + query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`); + } + + 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 create(input: CreateCommunicationInput, userId: string) { + const { data, error } = await (this.client.from as CallableFunction)( + 'module_communications', + ) + .insert({ + account_id: input.accountId, + module: 'bookings', + entity_id: input.entityId, + type: input.type, + direction: input.direction ?? 'internal', + subject: input.subject ?? null, + body: input.body ?? null, + email_to: input.emailTo ?? null, + email_cc: input.emailCc ?? null, + attachment_paths: input.attachmentPaths ?? null, + created_by: userId, + }) + .select( + 'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by', + ) + .single(); + + if (error) throw error; + return data; + } + + async delete(communicationId: string) { + const { error } = await (this.client.from as CallableFunction)( + 'module_communications', + ) + .delete() + .eq('id', communicationId) + .eq('module', 'bookings'); + + if (error) throw error; + } +} diff --git a/packages/features/booking-management/src/server/services/booking-export.service.ts b/packages/features/booking-management/src/server/services/booking-export.service.ts new file mode 100644 index 000000000..d309fbdbf --- /dev/null +++ b/packages/features/booking-management/src/server/services/booking-export.service.ts @@ -0,0 +1,76 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createBookingExportService(client: SupabaseClient) { + return new BookingExportService(client); +} + +class BookingExportService { + constructor(private readonly client: SupabaseClient) {} + + async exportBookingsCsv( + accountId: string, + filters?: { status?: string }, + ): Promise { + let query = this.client + .from('bookings') + .select('*, rooms(room_number, name), guests(first_name, last_name)') + .eq('account_id', accountId) + .order('check_in', { ascending: false }); + + if (filters?.status) { + query = query.eq('status', filters.status as any); + } + + const { data: bookings, error } = await query; + if (error) throw error; + if (!bookings?.length) return ''; + + const headers = ['Zimmer', 'Gast', 'Anreise', 'Abreise', 'Status', 'Preis']; + + const rows = bookings.map((b) => { + const room = (b as any).rooms; + const guest = (b as any).guests; + const roomLabel = room + ? `${room.room_number}${room.name ? ` (${room.name})` : ''}` + : ''; + const guestLabel = guest ? `${guest.first_name} ${guest.last_name}` : ''; + + return [ + roomLabel, + guestLabel, + b.check_in ?? '', + b.check_out ?? '', + b.status, + b.total_price?.toString() ?? '0', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(';'); + }); + + return [headers.join(';'), ...rows].join('\n'); + } + + async exportGuestsCsv(accountId: string): Promise { + const { data: guests, error } = await this.client + .from('guests') + .select('*') + .eq('account_id', accountId) + .order('last_name'); + + if (error) throw error; + if (!guests?.length) return ''; + + const headers = ['Vorname', 'Nachname', 'E-Mail', 'Telefon', 'Ort']; + + const rows = guests.map((g) => + [g.first_name, g.last_name, g.email ?? '', g.phone ?? '', g.city ?? ''] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(';'), + ); + + return [headers.join(';'), ...rows].join('\n'); + } +} diff --git a/packages/features/booking-management/src/server/services/booking-notification.service.ts b/packages/features/booking-management/src/server/services/booking-notification.service.ts new file mode 100644 index 000000000..4f18e785b --- /dev/null +++ b/packages/features/booking-management/src/server/services/booking-notification.service.ts @@ -0,0 +1,156 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { getLogger } from '@kit/shared/logger'; +import type { Database } from '@kit/supabase/database'; + +const NAMESPACE = 'booking-notification'; +const MODULE = 'bookings'; + +interface NotificationRule { + id: string; + channel: 'in_app' | 'email' | 'both'; + recipient_type: string; + recipient_config: Record; + subject_template: string | null; + message_template: string; +} + +export function createBookingNotificationService( + client: SupabaseClient, +) { + return { + async enqueue( + accountId: string, + triggerEvent: string, + entityId: string, + context: Record, + ) { + await (client.rpc as CallableFunction)('enqueue_module_notification', { + p_account_id: accountId, + p_module: MODULE, + p_trigger_event: triggerEvent, + p_entity_id: entityId, + p_context: context, + }); + }, + + async processPending(): Promise<{ processed: number; sent: number }> { + const logger = await getLogger(); + + const { data: pending, error } = await (client.from as CallableFunction)( + 'pending_module_notifications', + ) + .select('*') + .eq('module', MODULE) + .is('processed_at', null) + .order('created_at') + .limit(100); + + if (error || !pending?.length) return { processed: 0, sent: 0 }; + + let sent = 0; + + for (const n of pending as Array<{ + id: number; + account_id: string; + trigger_event: string; + context: Record; + }>) { + try { + sent += await this.dispatch( + n.account_id, + n.trigger_event, + n.context ?? {}, + ); + } catch (e) { + logger.error( + { name: NAMESPACE, id: n.id, error: e }, + 'Dispatch failed', + ); + } + + await (client.from as CallableFunction)('pending_module_notifications') + .update({ processed_at: new Date().toISOString() }) + .eq('id', n.id); + } + + logger.info( + { name: NAMESPACE, processed: pending.length, sent }, + 'Batch processed', + ); + return { processed: pending.length, sent }; + }, + + async dispatch( + accountId: string, + triggerEvent: string, + context: Record, + ): Promise { + const { data: rules } = await (client.from as CallableFunction)( + 'module_notification_rules', + ) + .select('*') + .eq('account_id', accountId) + .eq('module', MODULE) + .eq('trigger_event', triggerEvent) + .eq('is_active', true); + + if (!rules?.length) return 0; + + let sent = 0; + + for (const rule of rules as NotificationRule[]) { + const message = renderTemplate(rule.message_template, context); + + if (rule.channel === 'in_app' || rule.channel === 'both') { + const { createNotificationsApi } = + await import('@kit/notifications/api'); + const api = createNotificationsApi(client); + await api.createNotification({ + account_id: accountId, + body: message, + type: 'info', + channel: 'in_app', + }); + sent++; + } + + if (rule.channel === 'email' || rule.channel === 'both') { + const subject = rule.subject_template + ? renderTemplate(rule.subject_template, context) + : triggerEvent; + const email = context.email as string | undefined; + + if (email) { + const { getMailer } = await import('@kit/mailers'); + const mailer = await getMailer(); + await mailer.sendEmail({ + to: email, + from: process.env.EMAIL_SENDER ?? 'noreply@example.com', + subject, + html: `
${message}
`, + }); + sent++; + } + } + } + + return sent; + }, + }; +} + +function renderTemplate( + template: string, + context: Record, +): string { + let result = template; + for (const [key, value] of Object.entries(context)) { + result = result.replace( + new RegExp(`\\{\\{${key}\\}\\}`, 'g'), + String(value ?? ''), + ); + } + return result; +} diff --git a/packages/features/course-management/src/server/services/course-communication.service.ts b/packages/features/course-management/src/server/services/course-communication.service.ts new file mode 100644 index 000000000..969e164c9 --- /dev/null +++ b/packages/features/course-management/src/server/services/course-communication.service.ts @@ -0,0 +1,99 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +interface CommunicationListOptions { + type?: string; + direction?: string; + search?: string; + page?: number; + pageSize?: number; +} + +interface CreateCommunicationInput { + accountId: string; + entityId: string; + type: string; + direction?: string; + subject?: string; + body?: string; + emailTo?: string; + emailCc?: string; + attachmentPaths?: string[]; +} + +export function createCourseCommunicationService( + client: SupabaseClient, +) { + return new CourseCommunicationService(client); +} + +class CourseCommunicationService { + constructor(private readonly client: SupabaseClient) {} + + async list( + courseId: string, + accountId: string, + opts?: CommunicationListOptions, + ) { + let query = (this.client.from as CallableFunction)('module_communications') + .select('*', { count: 'exact' }) + .eq('module', 'courses') + .eq('entity_id', courseId) + .eq('account_id', accountId) + .order('created_at', { ascending: false }); + + if (opts?.type) query = query.eq('type', opts.type); + if (opts?.direction) query = query.eq('direction', opts.direction); + if (opts?.search) { + const escaped = opts.search.replace(/[%_\\]/g, '\\$&'); + query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`); + } + + 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 create(input: CreateCommunicationInput, userId: string) { + const { data, error } = await (this.client.from as CallableFunction)( + 'module_communications', + ) + .insert({ + account_id: input.accountId, + module: 'courses', + entity_id: input.entityId, + type: input.type, + direction: input.direction ?? 'internal', + subject: input.subject ?? null, + body: input.body ?? null, + email_to: input.emailTo ?? null, + email_cc: input.emailCc ?? null, + attachment_paths: input.attachmentPaths ?? null, + created_by: userId, + }) + .select( + 'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by', + ) + .single(); + + if (error) throw error; + return data; + } + + async delete(communicationId: string) { + const { error } = await (this.client.from as CallableFunction)( + 'module_communications', + ) + .delete() + .eq('id', communicationId) + .eq('module', 'courses'); + + if (error) throw error; + } +} diff --git a/packages/features/course-management/src/server/services/course-export.service.ts b/packages/features/course-management/src/server/services/course-export.service.ts new file mode 100644 index 000000000..a1abd0f88 --- /dev/null +++ b/packages/features/course-management/src/server/services/course-export.service.ts @@ -0,0 +1,94 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createCourseExportService(client: SupabaseClient) { + return new CourseExportService(client); +} + +class CourseExportService { + constructor(private readonly client: SupabaseClient) {} + + async exportParticipantsCsv(courseId: string): Promise { + const { data: participants, error } = await this.client + .from('course_participants') + .select('*') + .eq('course_id', courseId) + .order('last_name'); + + if (error) throw error; + if (!participants?.length) return ''; + + const headers = [ + 'Vorname', + 'Nachname', + 'E-Mail', + 'Telefon', + 'Status', + 'Anmeldedatum', + ]; + + const rows = participants.map((p) => + [ + p.first_name, + p.last_name, + p.email ?? '', + p.phone ?? '', + p.status, + p.enrolled_at ?? '', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(';'), + ); + + return [headers.join(';'), ...rows].join('\n'); + } + + async exportCoursesCsv( + accountId: string, + filters?: { status?: string }, + ): Promise { + let query = this.client + .from('courses') + .select('*') + .eq('account_id', accountId) + .order('start_date', { ascending: false }); + + if (filters?.status) { + query = query.eq('status', filters.status as any); + } + + const { data: courses, error } = await query; + if (error) throw error; + if (!courses?.length) return ''; + + const headers = [ + 'Kursnr.', + 'Name', + 'Status', + 'Startdatum', + 'Enddatum', + 'Gebuhr', + 'Kapazitat', + 'Min. Teilnehmer', + ]; + + const rows = courses.map((c) => + [ + c.course_number ?? '', + c.name, + c.status, + c.start_date ?? '', + c.end_date ?? '', + c.fee?.toString() ?? '0', + c.capacity?.toString() ?? '', + c.min_participants?.toString() ?? '', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(';'), + ); + + return [headers.join(';'), ...rows].join('\n'); + } +} diff --git a/packages/features/course-management/src/server/services/course-notification.service.ts b/packages/features/course-management/src/server/services/course-notification.service.ts new file mode 100644 index 000000000..68d198e98 --- /dev/null +++ b/packages/features/course-management/src/server/services/course-notification.service.ts @@ -0,0 +1,156 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { getLogger } from '@kit/shared/logger'; +import type { Database } from '@kit/supabase/database'; + +const NAMESPACE = 'course-notification'; +const MODULE = 'courses'; + +interface NotificationRule { + id: string; + channel: 'in_app' | 'email' | 'both'; + recipient_type: string; + recipient_config: Record; + subject_template: string | null; + message_template: string; +} + +export function createCourseNotificationService( + client: SupabaseClient, +) { + return { + async enqueue( + accountId: string, + triggerEvent: string, + entityId: string, + context: Record, + ) { + await (client.rpc as CallableFunction)('enqueue_module_notification', { + p_account_id: accountId, + p_module: MODULE, + p_trigger_event: triggerEvent, + p_entity_id: entityId, + p_context: context, + }); + }, + + async processPending(): Promise<{ processed: number; sent: number }> { + const logger = await getLogger(); + + const { data: pending, error } = await (client.from as CallableFunction)( + 'pending_module_notifications', + ) + .select('*') + .eq('module', MODULE) + .is('processed_at', null) + .order('created_at') + .limit(100); + + if (error || !pending?.length) return { processed: 0, sent: 0 }; + + let sent = 0; + + for (const n of pending as Array<{ + id: number; + account_id: string; + trigger_event: string; + context: Record; + }>) { + try { + sent += await this.dispatch( + n.account_id, + n.trigger_event, + n.context ?? {}, + ); + } catch (e) { + logger.error( + { name: NAMESPACE, id: n.id, error: e }, + 'Dispatch failed', + ); + } + + await (client.from as CallableFunction)('pending_module_notifications') + .update({ processed_at: new Date().toISOString() }) + .eq('id', n.id); + } + + logger.info( + { name: NAMESPACE, processed: pending.length, sent }, + 'Batch processed', + ); + return { processed: pending.length, sent }; + }, + + async dispatch( + accountId: string, + triggerEvent: string, + context: Record, + ): Promise { + const { data: rules } = await (client.from as CallableFunction)( + 'module_notification_rules', + ) + .select('*') + .eq('account_id', accountId) + .eq('module', MODULE) + .eq('trigger_event', triggerEvent) + .eq('is_active', true); + + if (!rules?.length) return 0; + + let sent = 0; + + for (const rule of rules as NotificationRule[]) { + const message = renderTemplate(rule.message_template, context); + + if (rule.channel === 'in_app' || rule.channel === 'both') { + const { createNotificationsApi } = + await import('@kit/notifications/api'); + const api = createNotificationsApi(client); + await api.createNotification({ + account_id: accountId, + body: message, + type: 'info', + channel: 'in_app', + }); + sent++; + } + + if (rule.channel === 'email' || rule.channel === 'both') { + const subject = rule.subject_template + ? renderTemplate(rule.subject_template, context) + : triggerEvent; + const email = context.email as string | undefined; + + if (email) { + const { getMailer } = await import('@kit/mailers'); + const mailer = await getMailer(); + await mailer.sendEmail({ + to: email, + from: process.env.EMAIL_SENDER ?? 'noreply@example.com', + subject, + html: `
${message}
`, + }); + sent++; + } + } + } + + return sent; + }, + }; +} + +function renderTemplate( + template: string, + context: Record, +): string { + let result = template; + for (const [key, value] of Object.entries(context)) { + result = result.replace( + new RegExp(`\\{\\{${key}\\}\\}`, 'g'), + String(value ?? ''), + ); + } + return result; +} diff --git a/packages/features/event-management/src/server/services/event-communication.service.ts b/packages/features/event-management/src/server/services/event-communication.service.ts new file mode 100644 index 000000000..4749afac2 --- /dev/null +++ b/packages/features/event-management/src/server/services/event-communication.service.ts @@ -0,0 +1,99 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +interface CommunicationListOptions { + type?: string; + direction?: string; + search?: string; + page?: number; + pageSize?: number; +} + +interface CreateCommunicationInput { + accountId: string; + entityId: string; + type: string; + direction?: string; + subject?: string; + body?: string; + emailTo?: string; + emailCc?: string; + attachmentPaths?: string[]; +} + +export function createEventCommunicationService( + client: SupabaseClient, +) { + return new EventCommunicationService(client); +} + +class EventCommunicationService { + constructor(private readonly client: SupabaseClient) {} + + async list( + eventId: string, + accountId: string, + opts?: CommunicationListOptions, + ) { + let query = (this.client.from as CallableFunction)('module_communications') + .select('*', { count: 'exact' }) + .eq('module', 'events') + .eq('entity_id', eventId) + .eq('account_id', accountId) + .order('created_at', { ascending: false }); + + if (opts?.type) query = query.eq('type', opts.type); + if (opts?.direction) query = query.eq('direction', opts.direction); + if (opts?.search) { + const escaped = opts.search.replace(/[%_\\]/g, '\\$&'); + query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`); + } + + 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 create(input: CreateCommunicationInput, userId: string) { + const { data, error } = await (this.client.from as CallableFunction)( + 'module_communications', + ) + .insert({ + account_id: input.accountId, + module: 'events', + entity_id: input.entityId, + type: input.type, + direction: input.direction ?? 'internal', + subject: input.subject ?? null, + body: input.body ?? null, + email_to: input.emailTo ?? null, + email_cc: input.emailCc ?? null, + attachment_paths: input.attachmentPaths ?? null, + created_by: userId, + }) + .select( + 'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by', + ) + .single(); + + if (error) throw error; + return data; + } + + async delete(communicationId: string) { + const { error } = await (this.client.from as CallableFunction)( + 'module_communications', + ) + .delete() + .eq('id', communicationId) + .eq('module', 'events'); + + if (error) throw error; + } +} diff --git a/packages/features/event-management/src/server/services/event-export.service.ts b/packages/features/event-management/src/server/services/event-export.service.ts new file mode 100644 index 000000000..313b8dc6c --- /dev/null +++ b/packages/features/event-management/src/server/services/event-export.service.ts @@ -0,0 +1,84 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createEventExportService(client: SupabaseClient) { + return new EventExportService(client); +} + +class EventExportService { + constructor(private readonly client: SupabaseClient) {} + + async exportRegistrationsCsv(eventId: string): Promise { + const { data: registrations, error } = await this.client + .from('event_registrations') + .select('*') + .eq('event_id', eventId) + .order('last_name'); + + if (error) throw error; + if (!registrations?.length) return ''; + + const headers = [ + 'Vorname', + 'Nachname', + 'E-Mail', + 'Telefon', + 'Geburtsdatum', + 'Status', + 'Anmeldedatum', + ]; + + const rows = registrations.map((r) => + [ + r.first_name, + r.last_name, + r.email ?? '', + r.phone ?? '', + r.date_of_birth ?? '', + r.status, + r.created_at ?? '', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(';'), + ); + + return [headers.join(';'), ...rows].join('\n'); + } + + async exportEventsCsv( + accountId: string, + filters?: { status?: string }, + ): Promise { + let query = this.client + .from('events') + .select('*') + .eq('account_id', accountId) + .order('event_date', { ascending: false }); + + if (filters?.status) { + query = query.eq('status', filters.status as any); + } + + const { data: events, error } = await query; + if (error) throw error; + if (!events?.length) return ''; + + const headers = ['Name', 'Status', 'Datum', 'Ort', 'Kapazitat']; + + const rows = events.map((e) => + [ + e.name, + e.status, + e.event_date ?? '', + e.location ?? '', + e.capacity?.toString() ?? '', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(';'), + ); + + return [headers.join(';'), ...rows].join('\n'); + } +} diff --git a/packages/features/event-management/src/server/services/event-notification.service.ts b/packages/features/event-management/src/server/services/event-notification.service.ts new file mode 100644 index 000000000..f8e294b3d --- /dev/null +++ b/packages/features/event-management/src/server/services/event-notification.service.ts @@ -0,0 +1,156 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { getLogger } from '@kit/shared/logger'; +import type { Database } from '@kit/supabase/database'; + +const NAMESPACE = 'event-notification'; +const MODULE = 'events'; + +interface NotificationRule { + id: string; + channel: 'in_app' | 'email' | 'both'; + recipient_type: string; + recipient_config: Record; + subject_template: string | null; + message_template: string; +} + +export function createEventNotificationService( + client: SupabaseClient, +) { + return { + async enqueue( + accountId: string, + triggerEvent: string, + entityId: string, + context: Record, + ) { + await (client.rpc as CallableFunction)('enqueue_module_notification', { + p_account_id: accountId, + p_module: MODULE, + p_trigger_event: triggerEvent, + p_entity_id: entityId, + p_context: context, + }); + }, + + async processPending(): Promise<{ processed: number; sent: number }> { + const logger = await getLogger(); + + const { data: pending, error } = await (client.from as CallableFunction)( + 'pending_module_notifications', + ) + .select('*') + .eq('module', MODULE) + .is('processed_at', null) + .order('created_at') + .limit(100); + + if (error || !pending?.length) return { processed: 0, sent: 0 }; + + let sent = 0; + + for (const n of pending as Array<{ + id: number; + account_id: string; + trigger_event: string; + context: Record; + }>) { + try { + sent += await this.dispatch( + n.account_id, + n.trigger_event, + n.context ?? {}, + ); + } catch (e) { + logger.error( + { name: NAMESPACE, id: n.id, error: e }, + 'Dispatch failed', + ); + } + + await (client.from as CallableFunction)('pending_module_notifications') + .update({ processed_at: new Date().toISOString() }) + .eq('id', n.id); + } + + logger.info( + { name: NAMESPACE, processed: pending.length, sent }, + 'Batch processed', + ); + return { processed: pending.length, sent }; + }, + + async dispatch( + accountId: string, + triggerEvent: string, + context: Record, + ): Promise { + const { data: rules } = await (client.from as CallableFunction)( + 'module_notification_rules', + ) + .select('*') + .eq('account_id', accountId) + .eq('module', MODULE) + .eq('trigger_event', triggerEvent) + .eq('is_active', true); + + if (!rules?.length) return 0; + + let sent = 0; + + for (const rule of rules as NotificationRule[]) { + const message = renderTemplate(rule.message_template, context); + + if (rule.channel === 'in_app' || rule.channel === 'both') { + const { createNotificationsApi } = + await import('@kit/notifications/api'); + const api = createNotificationsApi(client); + await api.createNotification({ + account_id: accountId, + body: message, + type: 'info', + channel: 'in_app', + }); + sent++; + } + + if (rule.channel === 'email' || rule.channel === 'both') { + const subject = rule.subject_template + ? renderTemplate(rule.subject_template, context) + : triggerEvent; + const email = context.email as string | undefined; + + if (email) { + const { getMailer } = await import('@kit/mailers'); + const mailer = await getMailer(); + await mailer.sendEmail({ + to: email, + from: process.env.EMAIL_SENDER ?? 'noreply@example.com', + subject, + html: `
${message}
`, + }); + sent++; + } + } + } + + return sent; + }, + }; +} + +function renderTemplate( + template: string, + context: Record, +): string { + let result = template; + for (const [key, value] of Object.entries(context)) { + result = result.replace( + new RegExp(`\\{\\{${key}\\}\\}`, 'g'), + String(value ?? ''), + ); + } + return result; +} diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 1307ff791..d83bd525e 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -484,6 +484,68 @@ export type Database = { }, ] } + booking_audit_log: { + Row: { + account_id: string + action: string + booking_id: string + changes: Json + created_at: string + id: number + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + booking_id: string + changes?: Json + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + booking_id?: string + changes?: Json + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "booking_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "booking_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "booking_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "booking_audit_log_booking_id_fkey" + columns: ["booking_id"] + isOneToOne: false + referencedRelation: "bookings" + referencedColumns: ["id"] + }, + ] + } bookings: { Row: { account_id: string @@ -492,6 +554,7 @@ export type Database = { check_out: string children: number created_at: string + created_by: string | null extras: Json guest_id: string | null id: string @@ -500,6 +563,8 @@ export type Database = { status: string total_price: number updated_at: string + updated_by: string | null + version: number } Insert: { account_id: string @@ -508,6 +573,7 @@ export type Database = { check_out: string children?: number created_at?: string + created_by?: string | null extras?: Json guest_id?: string | null id?: string @@ -516,6 +582,8 @@ export type Database = { status?: string total_price?: number updated_at?: string + updated_by?: string | null + version?: number } Update: { account_id?: string @@ -524,6 +592,7 @@ export type Database = { check_out?: string children?: number created_at?: string + created_by?: string | null extras?: Json guest_id?: string | null id?: string @@ -532,6 +601,8 @@ export type Database = { status?: string total_price?: number updated_at?: string + updated_by?: string | null + version?: number } Relationships: [ { @@ -1566,6 +1637,68 @@ export type Database = { }, ] } + course_audit_log: { + Row: { + account_id: string + action: string + changes: Json + course_id: string + created_at: string + id: number + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + changes?: Json + course_id: string + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + changes?: Json + course_id?: string + created_at?: string + id?: never + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "course_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_audit_log_course_id_fkey" + columns: ["course_id"] + isOneToOne: false + referencedRelation: "courses" + referencedColumns: ["id"] + }, + ] + } course_categories: { Row: { account_id: string @@ -1859,6 +1992,7 @@ export type Database = { category_id: string | null course_number: string | null created_at: string + created_by: string | null custom_data: Json description: string | null end_date: string | null @@ -1874,6 +2008,8 @@ export type Database = { start_date: string | null status: string updated_at: string + updated_by: string | null + version: number } Insert: { account_id: string @@ -1881,6 +2017,7 @@ export type Database = { category_id?: string | null course_number?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -1896,6 +2033,8 @@ export type Database = { start_date?: string | null status?: string updated_at?: string + updated_by?: string | null + version?: number } Update: { account_id?: string @@ -1903,6 +2042,7 @@ export type Database = { category_id?: string | null course_number?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -1918,6 +2058,8 @@ export type Database = { start_date?: string | null status?: string updated_at?: string + updated_by?: string | null + version?: number } Relationships: [ { @@ -2104,6 +2246,68 @@ export type Database = { }, ] } + event_audit_log: { + Row: { + account_id: string + action: string + changes: Json + created_at: string + event_id: string + id: number + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + changes?: Json + created_at?: string + event_id: string + id?: never + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + changes?: Json + created_at?: string + event_id?: string + id?: never + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "event_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "event_audit_log_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, + ] + } event_registrations: { Row: { created_at: string @@ -2113,11 +2317,13 @@ export type Database = { first_name: string id: string last_name: string + member_id: string | null notes: string | null parent_name: string | null parent_phone: string | null phone: string | null status: string + updated_at: string | null } Insert: { created_at?: string @@ -2127,11 +2333,13 @@ export type Database = { first_name: string id?: string last_name: string + member_id?: string | null notes?: string | null parent_name?: string | null parent_phone?: string | null phone?: string | null status?: string + updated_at?: string | null } Update: { created_at?: string @@ -2141,11 +2349,13 @@ export type Database = { first_name?: string id?: string last_name?: string + member_id?: string | null notes?: string | null parent_name?: string | null parent_phone?: string | null phone?: string | null status?: string + updated_at?: string | null } Relationships: [ { @@ -2155,6 +2365,13 @@ export type Database = { referencedRelation: "events" referencedColumns: ["id"] }, + { + foreignKeyName: "event_registrations_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, ] } events: { @@ -2165,6 +2382,7 @@ export type Database = { contact_name: string | null contact_phone: string | null created_at: string + created_by: string | null custom_data: Json description: string | null end_date: string | null @@ -2180,6 +2398,8 @@ export type Database = { shared_with_hierarchy: boolean status: string updated_at: string + updated_by: string | null + version: number } Insert: { account_id: string @@ -2188,6 +2408,7 @@ export type Database = { contact_name?: string | null contact_phone?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -2203,6 +2424,8 @@ export type Database = { shared_with_hierarchy?: boolean status?: string updated_at?: string + updated_by?: string | null + version?: number } Update: { account_id?: string @@ -2211,6 +2434,7 @@ export type Database = { contact_name?: string | null contact_phone?: string | null created_at?: string + created_by?: string | null custom_data?: Json description?: string | null end_date?: string | null @@ -2226,6 +2450,8 @@ export type Database = { shared_with_hierarchy?: boolean status?: string updated_at?: string + updated_by?: string | null + version?: number } Relationships: [ { @@ -2785,6 +3011,61 @@ export type Database = { }, ] } + gdpr_retention_policies: { + Row: { + account_id: string + applies_to_status: string[] + auto_anonymize: boolean + created_at: string + id: string + policy_name: string + retention_days: number + updated_at: string + } + Insert: { + account_id: string + applies_to_status?: string[] + auto_anonymize?: boolean + created_at?: string + id?: string + policy_name?: string + retention_days?: number + updated_at?: string + } + Update: { + account_id?: string + applies_to_status?: string[] + auto_anonymize?: boolean + created_at?: string + id?: string + policy_name?: string + retention_days?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "gdpr_retention_policies_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "gdpr_retention_policies_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "gdpr_retention_policies_account_id_fkey" + columns: ["account_id"] + isOneToOne: true + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } guests: { Row: { account_id: string @@ -3425,6 +3706,68 @@ export type Database = { }, ] } + member_audit_log: { + Row: { + account_id: string + action: string + changes: Json + created_at: string + id: number + member_id: string + metadata: Json + user_id: string | null + } + Insert: { + account_id: string + action: string + changes?: Json + created_at?: string + id?: never + member_id: string + metadata?: Json + user_id?: string | null + } + Update: { + account_id?: string + action?: string + changes?: Json + created_at?: string + id?: never + member_id?: string + metadata?: Json + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "member_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_audit_log_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_audit_log_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } member_cards: { Row: { account_id: string @@ -3603,6 +3946,83 @@ export type Database = { }, ] } + member_communications: { + Row: { + account_id: string + attachment_paths: string[] | null + body: string | null + created_at: string + created_by: string + direction: string + email_cc: string | null + email_message_id: string | null + email_to: string | null + id: string + member_id: string + subject: string | null + type: string + } + Insert: { + account_id: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by: string + direction?: string + email_cc?: string | null + email_message_id?: string | null + email_to?: string | null + id?: string + member_id: string + subject?: string | null + type: string + } + Update: { + account_id?: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by?: string + direction?: string + email_cc?: string | null + email_message_id?: string | null + email_to?: string | null + id?: string + member_id?: string + subject?: string | null + type?: string + } + Relationships: [ + { + foreignKeyName: "member_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_communications_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } member_department_assignments: { Row: { department_id: string @@ -3741,6 +4161,125 @@ export type Database = { }, ] } + member_merges: { + Row: { + account_id: string + field_choices: Json + id: string + performed_at: string + performed_by: string + primary_member_id: string + references_moved: Json + secondary_member_id: string + secondary_snapshot: Json + } + Insert: { + account_id: string + field_choices: Json + id?: string + performed_at?: string + performed_by: string + primary_member_id: string + references_moved: Json + secondary_member_id: string + secondary_snapshot: Json + } + Update: { + account_id?: string + field_choices?: Json + id?: string + performed_at?: string + performed_by?: string + primary_member_id?: string + references_moved?: Json + secondary_member_id?: string + secondary_snapshot?: Json + } + Relationships: [ + { + foreignKeyName: "member_merges_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_merges_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_merges_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } + member_notification_rules: { + Row: { + account_id: string + channel: string + created_at: string + id: string + is_active: boolean + message_template: string + recipient_config: Json + recipient_type: string + subject_template: string | null + trigger_event: string + } + Insert: { + account_id: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template: string + recipient_config?: Json + recipient_type: string + subject_template?: string | null + trigger_event: string + } + Update: { + account_id?: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template?: string + recipient_config?: Json + recipient_type?: string + subject_template?: string | null + trigger_event?: string + } + Relationships: [ + { + foreignKeyName: "member_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } member_portal_invitations: { Row: { accepted_at: string | null @@ -3871,6 +4410,94 @@ export type Database = { }, ] } + member_tag_assignments: { + Row: { + assigned_at: string + assigned_by: string | null + member_id: string + tag_id: string + } + Insert: { + assigned_at?: string + assigned_by?: string | null + member_id: string + tag_id: string + } + Update: { + assigned_at?: string + assigned_by?: string | null + member_id?: string + tag_id?: string + } + Relationships: [ + { + foreignKeyName: "member_tag_assignments_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_tag_assignments_tag_id_fkey" + columns: ["tag_id"] + isOneToOne: false + referencedRelation: "member_tags" + referencedColumns: ["id"] + }, + ] + } + member_tags: { + Row: { + account_id: string + color: string + created_at: string + description: string | null + id: string + name: string + sort_order: number + } + Insert: { + account_id: string + color?: string + created_at?: string + description?: string | null + id?: string + name: string + sort_order?: number + } + Update: { + account_id?: string + color?: string + created_at?: string + description?: string | null + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "member_tags_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_tags_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "member_tags_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } member_transfers: { Row: { cleared_data: Json @@ -4013,6 +4640,7 @@ export type Database = { phone: string | null phone2: string | null postal_code: string | null + primary_mandate_id: string | null salutation: string | null sepa_bank_name: string | null sepa_mandate_date: string | null @@ -4029,6 +4657,7 @@ export type Database = { updated_at: string updated_by: string | null user_id: string | null + version: number } Insert: { account_holder?: string | null @@ -4088,6 +4717,7 @@ export type Database = { phone?: string | null phone2?: string | null postal_code?: string | null + primary_mandate_id?: string | null salutation?: string | null sepa_bank_name?: string | null sepa_mandate_date?: string | null @@ -4104,6 +4734,7 @@ export type Database = { updated_at?: string updated_by?: string | null user_id?: string | null + version?: number } Update: { account_holder?: string | null @@ -4163,6 +4794,7 @@ export type Database = { phone?: string | null phone2?: string | null postal_code?: string | null + primary_mandate_id?: string | null salutation?: string | null sepa_bank_name?: string | null sepa_mandate_date?: string | null @@ -4179,6 +4811,7 @@ export type Database = { updated_at?: string updated_by?: string | null user_id?: string | null + version?: number } Relationships: [ { @@ -4209,6 +4842,13 @@ export type Database = { referencedRelation: "user_accounts" referencedColumns: ["id"] }, + { + foreignKeyName: "members_primary_mandate_id_fkey" + columns: ["primary_mandate_id"] + isOneToOne: false + referencedRelation: "sepa_mandates" + referencedColumns: ["id"] + }, ] } membership_applications: { @@ -4303,6 +4943,76 @@ export type Database = { }, ] } + module_communications: { + Row: { + account_id: string + attachment_paths: string[] | null + body: string | null + created_at: string + created_by: string | null + direction: string + email_cc: string | null + email_to: string | null + entity_id: string + id: string + module: string + subject: string | null + type: string + } + Insert: { + account_id: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by?: string | null + direction?: string + email_cc?: string | null + email_to?: string | null + entity_id: string + id?: string + module: string + subject?: string | null + type?: string + } + Update: { + account_id?: string + attachment_paths?: string[] | null + body?: string | null + created_at?: string + created_by?: string | null + direction?: string + email_cc?: string | null + email_to?: string | null + entity_id?: string + id?: string + module?: string + subject?: string | null + type?: string + } + Relationships: [ + { + foreignKeyName: "module_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_communications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } module_fields: { Row: { allowed_mime_types: string[] | null @@ -4450,6 +5160,70 @@ export type Database = { }, ] } + module_notification_rules: { + Row: { + account_id: string + channel: string + created_at: string + id: string + is_active: boolean + message_template: string + module: string + recipient_config: Json + recipient_type: string + subject_template: string | null + trigger_event: string + } + Insert: { + account_id: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template: string + module: string + recipient_config?: Json + recipient_type?: string + subject_template?: string | null + trigger_event: string + } + Update: { + account_id?: string + channel?: string + created_at?: string + id?: string + is_active?: boolean + message_template?: string + module?: string + recipient_config?: Json + recipient_type?: string + subject_template?: string | null + trigger_event?: string + } + Relationships: [ + { + foreignKeyName: "module_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "module_notification_rules_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } module_permissions: { Row: { can_bulk_edit: boolean @@ -5223,6 +5997,91 @@ export type Database = { }, ] } + pending_member_notifications: { + Row: { + account_id: string + context: Json + created_at: string + id: number + member_id: string | null + processed_at: string | null + trigger_event: string + } + Insert: { + account_id: string + context?: Json + created_at?: string + id?: never + member_id?: string | null + processed_at?: string | null + trigger_event: string + } + Update: { + account_id?: string + context?: Json + created_at?: string + id?: never + member_id?: string | null + processed_at?: string | null + trigger_event?: string + } + Relationships: [] + } + pending_module_notifications: { + Row: { + account_id: string + context: Json + created_at: string + entity_id: string + id: number + module: string + processed_at: string | null + trigger_event: string + } + Insert: { + account_id: string + context?: Json + created_at?: string + entity_id: string + id?: never + module: string + processed_at?: string | null + trigger_event: string + } + Update: { + account_id?: string + context?: Json + created_at?: string + entity_id?: string + id?: never + module?: string + processed_at?: string | null + trigger_event?: string + } + Relationships: [ + { + foreignKeyName: "pending_module_notifications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pending_module_notifications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pending_module_notifications_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } permit_quotas: { Row: { business_year: number @@ -5369,6 +6228,96 @@ export type Database = { }, ] } + scheduled_job_configs: { + Row: { + account_id: string + config: Json + created_at: string + id: string + is_enabled: boolean + job_type: string + last_run_at: string | null + next_run_at: string | null + } + Insert: { + account_id: string + config?: Json + created_at?: string + id?: string + is_enabled?: boolean + job_type: string + last_run_at?: string | null + next_run_at?: string | null + } + Update: { + account_id?: string + config?: Json + created_at?: string + id?: string + is_enabled?: boolean + job_type?: string + last_run_at?: string | null + next_run_at?: string | null + } + Relationships: [ + { + foreignKeyName: "scheduled_job_configs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "scheduled_job_configs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "scheduled_job_configs_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } + scheduled_job_runs: { + Row: { + completed_at: string | null + id: string + job_config_id: string + result: Json | null + started_at: string + status: string + } + Insert: { + completed_at?: string | null + id?: string + job_config_id: string + result?: Json | null + started_at?: string + status?: string + } + Update: { + completed_at?: string | null + id?: string + job_config_id?: string + result?: Json | null + started_at?: string + status?: string + } + Relationships: [ + { + foreignKeyName: "scheduled_job_runs_job_config_id_fkey" + columns: ["job_config_id"] + isOneToOne: false + referencedRelation: "scheduled_job_configs" + referencedColumns: ["id"] + }, + ] + } sepa_batches: { Row: { account_id: string @@ -6221,10 +7170,26 @@ export type Database = { } Returns: Database["public"]["Tables"]["invitations"]["Row"][] } + anonymize_member: { + Args: { p_member_id: string; p_performed_by?: string } + Returns: undefined + } + approve_application: { + Args: { p_application_id: string; p_user_id: string } + Returns: string + } can_action_account_member: { Args: { target_team_account_id: string; target_user_id: string } Returns: boolean } + cancel_course_enrollment: { + Args: { p_participant_id: string } + Returns: Json + } + cancel_event_registration: { + Args: { p_registration_id: string } + Returns: Json + } check_duplicate_member: { Args: { p_account_id: string @@ -6241,6 +7206,16 @@ export type Database = { status: Database["public"]["Enums"]["membership_status"] }[] } + check_instructor_availability: { + Args: { + p_end_time: string + p_exclude_session_id?: string + p_instructor_id: string + p_session_date: string + p_start_time: string + } + Returns: boolean + } clone_template: { Args: { p_new_name?: string @@ -6260,6 +7235,21 @@ export type Database = { } Returns: number } + create_booking_atomic: { + Args: { + p_account_id: string + p_adults?: number + p_check_in?: string + p_check_out?: string + p_children?: number + p_guest_id?: string + p_notes?: string + p_room_id: string + p_status?: string + p_total_price?: number + } + Returns: string + } create_invitation: { Args: { account_id: string; email: string; role: string } Returns: { @@ -6315,6 +7305,32 @@ export type Database = { isSetofReturn: false } } + delete_member_communication: { + Args: { p_account_id: string; p_communication_id: string } + Returns: undefined + } + enforce_gdpr_retention_policies: { Args: never; Returns: number } + enqueue_module_notification: { + Args: { + p_account_id: string + p_context?: Json + p_entity_id: string + p_module: string + p_trigger_event: string + } + Returns: undefined + } + enroll_course_participant: { + Args: { + p_course_id: string + p_email?: string + p_first_name?: string + p_last_name?: string + p_member_id?: string + p_phone?: string + } + Returns: Json + } get_account_ancestors: { Args: { child_id: string }; Returns: string[] } get_account_depth: { Args: { account_id: string }; Returns: number } get_account_descendants: { Args: { root_id: string }; Returns: string[] } @@ -6349,6 +7365,35 @@ export type Database = { user_id: string }[] } + get_booking_statistics: { + Args: { p_account_id: string; p_from?: string; p_to?: string } + Returns: { + active_bookings: number + avg_stay_nights: number + checked_in_count: number + occupancy_rate: number + total_bookings: number + total_revenue: number + }[] + } + get_booking_timeline: { + Args: { + p_action_filter?: string + p_booking_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_email: string + user_id: string + }[] + } get_catch_statistics: { Args: { p_account_id: string; p_water_id?: string; p_year?: number } Returns: { @@ -6372,6 +7417,102 @@ export type Database = { }[] } get_config: { Args: never; Returns: Json } + get_course_attendance_summary: { + Args: { p_course_id: string } + Returns: { + attendance_rate: number + enrollment_status: Database["public"]["Enums"]["enrollment_status"] + participant_id: string + participant_name: string + sessions_attended: number + total_sessions: number + }[] + } + get_course_statistics: { + Args: { p_account_id: string } + Returns: { + avg_occupancy_rate: number + cancelled_courses: number + completed_courses: number + open_courses: number + running_courses: number + total_courses: number + total_participants: number + total_revenue: number + total_waitlisted: number + }[] + } + get_course_timeline: { + Args: { + p_action_filter?: string + p_course_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_email: string + user_id: string + }[] + } + get_department_distribution: { + Args: { p_account_id: string } + Returns: { + department_name: string + member_count: number + percentage: number + }[] + } + get_dues_collection_report: { + Args: { p_account_id: string } + Returns: { + category_name: string + collection_rate: number + expected_amount: number + member_count: number + paid_count: number + }[] + } + get_event_registration_counts: { + Args: { p_event_ids: string[] } + Returns: { + event_id: string + registration_count: number + }[] + } + get_event_statistics: { + Args: { p_account_id: string } + Returns: { + avg_occupancy_rate: number + past_events: number + total_events: number + total_registrations: number + upcoming_events: number + }[] + } + get_event_timeline: { + Args: { + p_action_filter?: string + p_event_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_email: string + user_id: string + }[] + } get_hierarchy_report: { Args: { root_account_id: string } Returns: { @@ -6404,6 +7545,25 @@ export type Database = { total_upcoming_events: number }[] } + get_member_demographics: { + Args: { p_account_id: string } + Returns: { + age_group: string + diverse_count: number + female_count: number + male_count: number + total: number + unknown_count: number + }[] + } + get_member_geographic_distribution: { + Args: { p_account_id: string } + Returns: { + city: string + member_count: number + postal_prefix: string + }[] + } get_member_quick_stats: { Args: { p_account_id: string } Returns: { @@ -6416,6 +7576,43 @@ export type Database = { total: number }[] } + get_member_retention: { + Args: { p_account_id: string; p_years?: number } + Returns: { + members_end: number + members_start: number + new_members: number + resigned_members: number + retention_rate: number + year: number + }[] + } + get_member_timeline: { + Args: { + p_action_filter?: string + p_member_id: string + p_page?: number + p_page_size?: number + } + Returns: { + action: string + changes: Json + created_at: string + id: number + metadata: Json + total_count: number + user_display_name: string + user_id: string + }[] + } + get_membership_duration_analysis: { + Args: { p_account_id: string } + Returns: { + duration_bucket: string + member_count: number + percentage: number + }[] + } get_next_member_number: { Args: { p_account_id: string } Returns: string @@ -6478,6 +7675,7 @@ export type Database = { } Returns: boolean } + install_extensions: { Args: never; Returns: undefined } is_aal2: { Args: never; Returns: boolean } is_account_owner: { Args: { account_id: string }; Returns: boolean } is_account_team_member: { @@ -6553,6 +7751,25 @@ export type Database = { template_type: string }[] } + log_member_audit_event: { + Args: { + p_account_id: string + p_action: string + p_changes?: Json + p_member_id: string + p_metadata?: Json + } + Returns: undefined + } + merge_members: { + Args: { + p_field_choices?: Json + p_performed_by?: string + p_primary_id: string + p_secondary_id: string + } + Returns: Json + } module_query: { Args: { p_filters?: Json @@ -6565,10 +7782,36 @@ export type Database = { } Returns: Json } + register_for_event: { + Args: { + p_date_of_birth?: string + p_email?: string + p_event_id: string + p_first_name?: string + p_last_name?: string + p_member_id?: string + p_parent_name?: string + p_parent_phone?: string + p_phone?: string + } + Returns: Json + } + reject_application: { + Args: { + p_application_id: string + p_review_notes?: string + p_user_id: string + } + Returns: undefined + } revoke_nonce: { Args: { p_id: string; p_reason?: string } Returns: boolean } + safe_delete_member: { + Args: { p_member_id: string; p_performed_by?: string } + Returns: undefined + } search_members_across_hierarchy: { Args: { account_filter?: string