diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index b2618e8..a45ae21 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -1,5 +1,15 @@ import type { InferSelectModel } from "drizzle-orm"; -import { pgTable, boolean, uuid, text, date, pgEnum, time, integer } from "drizzle-orm/pg-core"; +import { + pgTable, + boolean, + uuid, + text, + date, + pgEnum, + time, + integer, + json +} from "drizzle-orm/pg-core"; import { bytea } from "./base"; /** @@ -45,14 +55,16 @@ export const agent = pgTable("agent", { export const channel = pgTable("channel", { /** Primary key - unique identifier */ id: uuid("id").primaryKey().defaultRandom(), - /** Channel display name */ - name: text("name").notNull(), + /** Channel display names in multiple languages (array of strings in same order as languages) */ + names: json("names").$type().notNull(), /** Optional color for UI display (hex code) */ color: text("color"), - /** Optional description of the channel */ - description: text("description"), - /** Language for this channel's interface */ - language: text("language"), + /** Whether the channel is paused and does not offer nor accept new appointments */ + pause: boolean("paused").notNull().default(false), + /** Optional descriptions in multiple languages (array of strings in same order as languages) */ + descriptions: json("descriptions").$type(), + /** Active languages for this channel (array of language codes) */ + languages: json("languages").$type().notNull(), /** Whether channel is publicly bookable or requires internal access */ isPublic: boolean("is_public"), /** Whether appointments must be explicitly confirmed by staff */ @@ -69,8 +81,6 @@ export const channel = pgTable("channel", { export const slotTemplate = pgTable("slotTemplate", { /** Primary key - unique identifier */ id: uuid("id").primaryKey().defaultRandom(), - /** Template name for identification */ - name: text("name").notNull(), /** Bitmask for weekdays (1=Monday, 2=Tuesday, 4=Wednesday, etc.) */ weekdays: integer("weekdays"), /** Start time for the slot template */ diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index 5616d28..ecdf91f 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -6,6 +6,10 @@ import { eq, inArray } from "drizzle-orm"; import logger from "$lib/logger"; import z from "zod/v4"; import { ValidationError, NotFoundError } from "../utils/errors"; +import { TenantConfig } from "../db/tenant-config"; + +const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const; +const NEXT_COLOR_KEY = "nextChannelColor"; const slotTemplateSchema = z.object({ name: z.string().min(1).max(100), @@ -15,33 +19,56 @@ const slotTemplateSchema = z.object({ duration: z.number().int().min(1).max(1440) }); -const channelCreationSchema = z.object({ - name: z.string().min(1).max(100), - color: z.string().optional(), - description: z.string().optional(), - language: z.string().optional(), - isPublic: z.boolean().optional(), - requiresConfirmation: z.boolean().optional(), - agentIds: z.array(z.string().uuid()).optional().default([]), - slotTemplates: z.array(slotTemplateSchema).optional().default([]) -}); +const channelCreationSchema = z + .object({ + names: z.array(z.string().min(1).max(100)).min(1), + color: z.string().optional(), + descriptions: z.array(z.string()).optional(), + languages: z.array(z.string().min(2).max(5)).min(1), + isPublic: z.boolean().optional(), + requiresConfirmation: z.boolean().optional(), + agentIds: z.array(z.string().uuid()).optional().default([]), + slotTemplates: z.array(slotTemplateSchema).optional().default([]) + }) + .refine( + (data) => + !data.descriptions || + data.descriptions.length === 0 || + data.descriptions.length === data.languages.length, + { message: "descriptions array must have same length as languages array" } + ) + .refine((data) => data.names.length === data.languages.length, { + message: "names array must have same length as languages array" + }); -const channelUpdateSchema = z.object({ - name: z.string().min(1).max(100).optional(), - color: z.string().optional(), - description: z.string().optional(), - language: z.string().optional(), - isPublic: z.boolean().optional(), - requiresConfirmation: z.boolean().optional(), - agentIds: z.array(z.string().uuid()).optional(), - slotTemplates: z - .array( - slotTemplateSchema.extend({ - id: z.string().uuid().optional() - }) - ) - .optional() -}); +const channelUpdateSchema = z + .object({ + names: z.array(z.string().min(1).max(100)).optional(), + color: z.string().optional(), + descriptions: z.array(z.string()).optional(), + languages: z.array(z.string().min(2).max(5)).optional(), + isPublic: z.boolean().optional(), + requiresConfirmation: z.boolean().optional(), + agentIds: z.array(z.string().uuid()).optional(), + slotTemplates: z + .array( + slotTemplateSchema.extend({ + id: z.string().uuid().optional() + }) + ) + .optional() + }) + .refine( + (data) => + !data.descriptions || + !data.languages || + data.descriptions.length === 0 || + data.descriptions.length === data.languages.length, + { message: "descriptions array must have same length as languages array" } + ) + .refine((data) => !data.names || !data.languages || data.names.length === data.languages.length, { + message: "names array must have same length as languages array" + }); export type ChannelCreationRequest = z.infer; export type ChannelUpdateRequest = z.infer; @@ -91,9 +118,22 @@ export class ChannelService { throw new ValidationError("Invalid channel creation request"); } + if (!request.color) { + // Automatically set the channel color if none is given + const configService = await TenantConfig.create(this.tenantId); + const config = configService.configuration; + const nextIndex = config[NEXT_COLOR_KEY] as number; + request.color = CHANNEL_COLORS[nextIndex]; + await configService.setConfig( + NEXT_COLOR_KEY, + nextIndex + 1 === CHANNEL_COLORS.length ? 0 : nextIndex + 1 + ); + } + log.debug("Creating new channel", { tenantId: this.tenantId, - name: request.name, + names: request.names, + languages: request.languages, agentCount: request.agentIds?.length || 0, slotTemplateCount: request.slotTemplates?.length || 0 }); @@ -107,10 +147,10 @@ export class ChannelService { const channelResult = await tx .insert(tenantSchema.channel) .values({ - name: request.name, + names: request.names, color: request.color, - description: request.description, - language: request.language, + descriptions: request.descriptions || [], + languages: request.languages, isPublic: request.isPublic, requiresConfirmation: request.requiresConfirmation }) @@ -125,7 +165,6 @@ export class ChannelService { const slotTemplateResult = await tx .insert(tenantSchema.slotTemplate) .values({ - name: slotTemplateData.name, weekdays: slotTemplateData.weekdays, from: slotTemplateData.from, to: slotTemplateData.to, @@ -178,7 +217,8 @@ export class ChannelService { log.debug("Channel created successfully", { tenantId: this.tenantId, channelId: result.id, - name: result.name, + names: result.names, + languages: result.languages, agentCount: result.agents.length, slotTemplateCount: result.slotTemplates.length }); @@ -187,7 +227,7 @@ export class ChannelService { } catch (error) { log.error("Failed to create channel", { tenantId: this.tenantId, - name: request.name, + names: request.names, error: String(error) }); throw error; @@ -223,10 +263,10 @@ export class ChannelService { const result = await db.transaction(async (tx) => { // 1. Update channel basic data const channelData = { - name: updateData.name, + names: updateData.names, color: updateData.color, - description: updateData.description, - language: updateData.language, + descriptions: updateData.descriptions, + languages: updateData.languages, isPublic: updateData.isPublic, requiresConfirmation: updateData.requiresConfirmation }; @@ -332,7 +372,6 @@ export class ChannelService { const updateResult = await tx .update(tenantSchema.slotTemplate) .set({ - name: slotTemplateData.name, weekdays: slotTemplateData.weekdays, from: slotTemplateData.from, to: slotTemplateData.to, @@ -351,7 +390,6 @@ export class ChannelService { const createResult = await tx .insert(tenantSchema.slotTemplate) .values({ - name: slotTemplateData.name, weekdays: slotTemplateData.weekdays, from: slotTemplateData.from, to: slotTemplateData.to, @@ -407,7 +445,6 @@ export class ChannelService { slotTemplates = await tx .select({ id: tenantSchema.slotTemplate.id, - name: tenantSchema.slotTemplate.name, weekdays: tenantSchema.slotTemplate.weekdays, from: tenantSchema.slotTemplate.from, to: tenantSchema.slotTemplate.to, @@ -492,7 +529,6 @@ export class ChannelService { const slotTemplates = await db .select({ id: tenantSchema.slotTemplate.id, - name: tenantSchema.slotTemplate.name, weekdays: tenantSchema.slotTemplate.weekdays, from: tenantSchema.slotTemplate.from, to: tenantSchema.slotTemplate.to, @@ -544,7 +580,7 @@ export class ChannelService { const channels = await db .select() .from(tenantSchema.channel) - .orderBy(tenantSchema.channel.name); + .orderBy(tenantSchema.channel.names); // Get relations for each channel const result: ChannelWithRelations[] = []; diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 7b82265..5e6de57 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -27,10 +27,12 @@ export interface TenantConfiguration extends Record | null = null; #db: Awaited> | null = null; private constructor(public readonly tenantId: string) {} @@ -43,7 +45,8 @@ export class TenantAdminService { maxTeamMembers: -1, autoDeleteDays: 30, requireEmail: true, - requirePhone: false + requirePhone: false, + nextChannelColor: 0 }; } @@ -67,10 +70,7 @@ export class TenantAdminService { newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName; try { - const tenant = await centralDb - .insert(centralSchema.tenant) - .values(newTenant) - .returning({ id: centralSchema.tenant.id }); + const tenant = await centralDb.insert(centralSchema.tenant).values(newTenant).returning(); log.debug("Tenant created in database", { tenantId: tenant[0].id, @@ -110,6 +110,7 @@ export class TenantAdminService { const tenantService = new TenantAdminService(tenant[0].id); tenantService.#config = config; + tenantService.#tenant = tenant[0]; log.debug("Tenant service created successfully", { tenantId: tenant[0].id }); @@ -137,6 +138,14 @@ export class TenantAdminService { try { const tenant = new TenantAdminService(id); tenant.#config = await TenantConfig.create(id); + const data = await tenant.#db + ?.select() + .from(centralSchema.tenant) + .where(eq(centralSchema.tenant.id, id)) + .limit(1); + if (data) { + tenant.#tenant = data[0]; + } log.debug("Tenant service loaded successfully", { tenantId: id }); return tenant; @@ -153,6 +162,10 @@ export class TenantAdminService { return this.#config; } + get tenantData() { + return this.#tenant; + } + /** * Update tenant data (longName, shortName, description, logo) */ @@ -231,6 +244,50 @@ export class TenantAdminService { } } + /** + * Set the setup state of the tenant + */ + async setSetupState(setupState: "NEW" | "SETTINGS_CREATED" | "AGENTS_SET_UP" | "FIRST_CHANNEL_CREATED") { + const log = logger.setContext("TenantAdminService"); + log.debug("Setting tenant setup state", { + tenantId: this.tenantId, + setupState + }); + + try { + const result = await centralDb + .update(centralSchema.tenant) + .set({ + setupState, + updatedAt: new Date() + }) + .where(eq(centralSchema.tenant.id, this.tenantId)) + .returning(); + + if (!result[0]) { + log.warn("Tenant setup state update failed: Tenant not found", { tenantId: this.tenantId }); + throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); + } + + this.#tenant = result[0]; + + log.debug("Tenant setup state updated successfully", { + tenantId: this.tenantId, + setupState + }); + + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to set tenant setup state", { + tenantId: this.tenantId, + setupState, + error: String(error) + }); + throw error; + } + } + /** * Get the tenant's database connection (cached) */ diff --git a/src/routes/api/tenants/[id]/setup-state/+server.ts b/src/routes/api/tenants/[id]/setup-state/+server.ts new file mode 100644 index 0000000..90b08b6 --- /dev/null +++ b/src/routes/api/tenants/[id]/setup-state/+server.ts @@ -0,0 +1,149 @@ +import { json } from "@sveltejs/kit"; +import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; +import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import type { RequestHandler } from "@sveltejs/kit"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import logger from "$lib/logger"; +import z from "zod/v4"; + +const setupStateSchema = z.object({ + setupState: z.enum(["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]) +}); + +// Register OpenAPI documentation for PUT +registerOpenAPIRoute("/tenants/{id}/setup-state", "PUT", { + summary: "Update tenant setup state", + description: "Updates the setup state for a specific tenant", + tags: ["Tenants"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Tenant ID" + } + ], + requestBody: { + description: "Setup state update", + content: { + "application/json": { + schema: { + type: "object", + properties: { + setupState: { + type: "string", + enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], + description: "The new setup state for the tenant" + } + }, + required: ["setupState"] + } + } + } + }, + responses: { + "200": { + description: "Tenant setup state updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + tenant: { + type: "object", + properties: { + id: { type: "string", description: "Tenant ID" }, + setupState: { + type: "string", + enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], + description: "Current setup state" + }, + updatedAt: { + type: "string", + format: "date-time", + description: "Last update timestamp" + } + } + } + }, + required: ["message", "tenant"] + } + } + } + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "404": { + description: "Tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const PUT: RequestHandler = async ({ params, request }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + const body = await request.json(); + + log.debug("Updating tenant setup state", { + tenantId, + setupState: body.setupState + }); + + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } + + const validation = setupStateSchema.safeParse(body); + if (!validation.success) { + return json({ error: "Invalid setup state" }, { status: 400 }); + } + + const tenantService = await TenantAdminService.getTenantById(tenantId); + const updatedTenant = await tenantService.setSetupState(validation.data.setupState); + + log.debug("Tenant setup state updated successfully", { + tenantId, + setupState: validation.data.setupState + }); + + return json({ + message: "Tenant setup state updated successfully", + tenant: updatedTenant + }); + } catch (error) { + log.error("Error updating tenant setup state:", JSON.stringify(error || "?")); + + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } + + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } + + return json({ error: "Internal server error" }, { status: 500 }); + } +}; \ No newline at end of file