#53 slot templates are not named. channels are multilingual and can be paused.

#53 Setup state of tenant should be settable by setup ui
This commit is contained in:
Hendrik Belitz
2025-07-21 09:22:24 +02:00
parent 220ae4ff84
commit c0d48d92bc
4 changed files with 307 additions and 55 deletions
+19 -9
View File
@@ -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<string[]>().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<string[]>(),
/** Active languages for this channel (array of language codes) */
languages: json("languages").$type<string[]>().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 */
+77 -41
View File
@@ -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<typeof channelCreationSchema>;
export type ChannelUpdateRequest = z.infer<typeof channelUpdateSchema>;
@@ -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[] = [];
@@ -27,10 +27,12 @@ export interface TenantConfiguration extends Record<string, string | number | bo
autoDeleteDays: number;
requireEmail: boolean;
requirePhone: boolean;
nextChannelColor: number;
}
export class TenantAdminService {
#config!: TenantConfig;
#tenant: Awaited<centralSchema.SelectTenant> | null = null;
#db: Awaited<ReturnType<typeof getTenantDb>> | 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)
*/
@@ -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 });
}
};