mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-28 19:54:50 +02:00
#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:
@@ -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 */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user