Add editable site name and finish service image upload
User asked to remove the hardcoded "TeaBoy" branding and let admins change the system name. Also completes the in-progress service image upload work via App Storage. Changes: - New app_settings table (single row id=1) with siteNameAr/siteNameEn - New /settings endpoints: GET (public) + PATCH (admin) - Atomic ensureSettingsRow via INSERT ... ON CONFLICT DO NOTHING to avoid race conditions - New SiteSettingsPanel tab in admin page (Arabic + English inputs) - New useAppName hook reads settings, updates document.title, falls back to defaults while loading - Login + register pages now display the dynamic site name - Service image upload (App Storage) wired via useUpload + presigned GCS URL flow; admin component ServiceImageUploader - Storage routes: /storage/uploads/request-url and /storage/objects/* now require auth (closes previously-open endpoints flagged by review) - Added AppSettings/UpdateAppSettingsBody + storage schemas to openapi.yaml; regenerated client and zod - Exposed UploadResponse from @workspace/object-storage-web; added composite:true so it can be referenced by teaboy-os tsconfig Validation: typechecks pass for api-server and teaboy-os; settings GET returns row; upload URL endpoint returns 401 without auth.
This commit is contained in:
@@ -262,6 +262,6 @@ async function signObjectURL({
|
||||
);
|
||||
}
|
||||
|
||||
const { signed_url: signedURL } = await response.json();
|
||||
return signedURL;
|
||||
const data = (await response.json()) as { signed_url: string };
|
||||
return data.signed_url;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import notificationsRouter from "./notifications";
|
||||
import usersRouter from "./users";
|
||||
import statsRouter from "./stats";
|
||||
import storageRouter from "./storage";
|
||||
import settingsRouter from "./settings";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -20,5 +21,6 @@ router.use(notificationsRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(statsRouter);
|
||||
router.use(storageRouter);
|
||||
router.use(settingsRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import { appSettingsTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
import { UpdateAppSettingsBody } from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
async function ensureSettingsRow() {
|
||||
await db
|
||||
.insert(appSettingsTable)
|
||||
.values({ id: 1 })
|
||||
.onConflictDoNothing({ target: appSettingsTable.id });
|
||||
}
|
||||
|
||||
async function readSettings() {
|
||||
await ensureSettingsRow();
|
||||
const [row] = await db.select().from(appSettingsTable).where(eq(appSettingsTable.id, 1));
|
||||
return row;
|
||||
}
|
||||
|
||||
router.get("/settings", async (_req, res): Promise<void> => {
|
||||
res.json(await readSettings());
|
||||
});
|
||||
|
||||
router.patch("/settings", requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = UpdateAppSettingsBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
await ensureSettingsRow();
|
||||
const [updated] = await db
|
||||
.update(appSettingsTable)
|
||||
.set(parsed.data)
|
||||
.where(eq(appSettingsTable.id, 1))
|
||||
.returning();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
RequestUploadUrlResponse,
|
||||
} from "@workspace/api-zod";
|
||||
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
|
||||
import { ObjectPermission } from "../lib/objectAcl";
|
||||
import { requireAuth } from "../middlewares/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
const objectStorageService = new ObjectStorageService();
|
||||
@@ -17,7 +17,7 @@ const objectStorageService = new ObjectStorageService();
|
||||
* The client sends JSON metadata (name, size, contentType) — NOT the file.
|
||||
* Then uploads the file directly to the returned presigned URL.
|
||||
*/
|
||||
router.post("/storage/uploads/request-url", async (req: Request, res: Response) => {
|
||||
router.post("/storage/uploads/request-url", requireAuth, async (req: Request, res: Response) => {
|
||||
const parsed = RequestUploadUrlBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "Missing or invalid required fields" });
|
||||
@@ -84,28 +84,13 @@ router.get("/storage/public-objects/*filePath", async (req: Request, res: Respon
|
||||
* These are served from a separate path from /public-objects and can optionally
|
||||
* be protected with authentication or ACL checks based on the use case.
|
||||
*/
|
||||
router.get("/storage/objects/*path", async (req: Request, res: Response) => {
|
||||
router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = req.params.path;
|
||||
const wildcardPath = Array.isArray(raw) ? raw.join("/") : raw;
|
||||
const objectPath = `/objects/${wildcardPath}`;
|
||||
const objectFile = await objectStorageService.getObjectEntityFile(objectPath);
|
||||
|
||||
// --- Protected route example (uncomment when using replit-auth) ---
|
||||
// if (!req.isAuthenticated()) {
|
||||
// res.status(401).json({ error: "Unauthorized" });
|
||||
// return;
|
||||
// }
|
||||
// const canAccess = await objectStorageService.canAccessObjectEntity({
|
||||
// userId: req.user.id,
|
||||
// objectFile,
|
||||
// requestedPermission: ObjectPermission.READ,
|
||||
// });
|
||||
// if (!canAccess) {
|
||||
// res.status(403).json({ error: "Forbidden" });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const response = await objectStorageService.downloadObject(objectFile);
|
||||
|
||||
res.status(response.status);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useGetAppSettings, getGetAppSettingsQueryKey } from "@workspace/api-client-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useAppSettings() {
|
||||
const query = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useAppName() {
|
||||
const { i18n } = useTranslation();
|
||||
const { data } = useAppSettings();
|
||||
const isAr = i18n.language === "ar";
|
||||
const fallback = isAr ? "نظام TeaBoy" : "TeaBoy OS";
|
||||
const name = data ? (isAr ? data.siteNameAr : data.siteNameEn) : fallback;
|
||||
useEffect(() => {
|
||||
if (typeof document !== "undefined" && data) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name, data]);
|
||||
return name;
|
||||
}
|
||||
@@ -90,7 +90,10 @@
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"roles": "الصلاحيات",
|
||||
"active": "نشط"
|
||||
"active": "نشط",
|
||||
"siteSettings": "إعدادات الموقع",
|
||||
"siteNameAr": "اسم الموقع (عربي)",
|
||||
"siteNameEn": "اسم الموقع (إنجليزي)"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "404 — الصفحة غير موجودة",
|
||||
|
||||
@@ -90,7 +90,10 @@
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"roles": "Roles",
|
||||
"active": "Active"
|
||||
"active": "Active",
|
||||
"siteSettings": "Site Settings",
|
||||
"siteNameAr": "Site Name (Arabic)",
|
||||
"siteNameEn": "Site Name (English)"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "404 — Page Not Found",
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useGetAppSettings,
|
||||
getGetAppSettingsQueryKey,
|
||||
useUpdateAppSettings,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload } from "@workspace/object-storage-web";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -306,6 +309,7 @@ export default function AdminPage() {
|
||||
<TabsTrigger value="apps" className="flex-1">{t("admin.manageApps")}</TabsTrigger>
|
||||
<TabsTrigger value="services" className="flex-1">{t("admin.manageServices")}</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex-1">{t("admin.manageUsers")}</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">{t("admin.siteSettings")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Apps Tab */}
|
||||
@@ -488,12 +492,68 @@ export default function AdminPage() {
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-3">
|
||||
<SiteSettingsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteSettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
||||
const update = useUpdateAppSettings();
|
||||
const [form, setForm] = useState({ siteNameAr: "", siteNameEn: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setForm({ siteNameAr: data.siteNameAr, siteNameEn: data.siteNameEn });
|
||||
}, [data]);
|
||||
|
||||
const handleSave = () => {
|
||||
update.mutate(
|
||||
{ data: form },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getGetAppSettingsQueryKey() });
|
||||
toast({ title: t("common.success") });
|
||||
},
|
||||
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.siteNameAr")}</Label>
|
||||
<Input
|
||||
value={form.siteNameAr}
|
||||
onChange={(e) => setForm({ ...form, siteNameAr: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.siteNameEn")}</Label>
|
||||
<Input
|
||||
value={form.siteNameEn}
|
||||
onChange={(e) => setForm({ ...form, siteNameEn: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={update.isPending} className="w-full">
|
||||
{update.isPending ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceImageUploader({
|
||||
value,
|
||||
onChange,
|
||||
@@ -504,10 +564,10 @@ function ServiceImageUploader({
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { uploadFile, isUploading, progress } = useUpload({
|
||||
onSuccess: (resp) => {
|
||||
onSuccess: (resp: UploadResponse) => {
|
||||
onChange(`/api/storage${resp.objectPath}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAppName } from "@/hooks/use-app-settings";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const appName = useAppName();
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -46,7 +48,7 @@ export default function LoginPage() {
|
||||
<div className="min-h-screen os-bg flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="text-4xl font-bold text-primary mb-2">{t("common.appName")}</div>
|
||||
<div className="text-4xl font-bold text-primary mb-2">{appName}</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t("auth.welcomeBack")}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAppName } from "@/hooks/use-app-settings";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const appName = useAppName();
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -48,7 +50,7 @@ export default function RegisterPage() {
|
||||
<div className="min-h-screen os-bg flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="text-4xl font-bold text-primary mb-2">{t("common.appName")}</div>
|
||||
<div className="text-4xl font-bold text-primary mb-2">{appName}</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t("auth.createAccount")}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -257,6 +257,25 @@ export interface Notification {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
siteNameAr: string;
|
||||
siteNameEn: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAppSettingsBody {
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 200
|
||||
*/
|
||||
siteNameAr?: string;
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 200
|
||||
*/
|
||||
siteNameEn?: string;
|
||||
}
|
||||
|
||||
export interface RequestUploadUrlBody {
|
||||
/** @minLength 1 */
|
||||
name: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
|
||||
import type {
|
||||
App,
|
||||
AppSettings,
|
||||
AuthUser,
|
||||
ConversationWithDetails,
|
||||
CreateAppBody,
|
||||
@@ -37,6 +38,7 @@ import type {
|
||||
ServiceCategory,
|
||||
SuccessResponse,
|
||||
UpdateAppBody,
|
||||
UpdateAppSettingsBody,
|
||||
UpdateLanguageBody,
|
||||
UpdateServiceBody,
|
||||
UpdateUserBody,
|
||||
@@ -926,6 +928,167 @@ export const useDeleteApp = <
|
||||
return useMutation(getDeleteAppMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get application settings (public)
|
||||
*/
|
||||
export const getGetAppSettingsUrl = () => {
|
||||
return `/api/settings`;
|
||||
};
|
||||
|
||||
export const getAppSettings = async (
|
||||
options?: RequestInit,
|
||||
): Promise<AppSettings> => {
|
||||
return customFetch<AppSettings>(getGetAppSettingsUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAppSettingsQueryKey = () => {
|
||||
return [`/api/settings`] as const;
|
||||
};
|
||||
|
||||
export const getGetAppSettingsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAppSettings>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAppSettings>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetAppSettingsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAppSettings>>> = ({
|
||||
signal,
|
||||
}) => getAppSettings({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAppSettings>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAppSettingsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAppSettings>>
|
||||
>;
|
||||
export type GetAppSettingsQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Get application settings (public)
|
||||
*/
|
||||
|
||||
export function useGetAppSettings<
|
||||
TData = Awaited<ReturnType<typeof getAppSettings>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAppSettings>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAppSettingsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update application settings (admin)
|
||||
*/
|
||||
export const getUpdateAppSettingsUrl = () => {
|
||||
return `/api/settings`;
|
||||
};
|
||||
|
||||
export const updateAppSettings = async (
|
||||
updateAppSettingsBody: UpdateAppSettingsBody,
|
||||
options?: RequestInit,
|
||||
): Promise<AppSettings> => {
|
||||
return customFetch<AppSettings>(getUpdateAppSettingsUrl(), {
|
||||
...options,
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(updateAppSettingsBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateAppSettingsMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAppSettings>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateAppSettingsBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAppSettings>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateAppSettingsBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["updateAppSettings"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateAppSettings>>,
|
||||
{ data: BodyType<UpdateAppSettingsBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateAppSettings(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateAppSettingsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateAppSettings>>
|
||||
>;
|
||||
export type UpdateAppSettingsMutationBody = BodyType<UpdateAppSettingsBody>;
|
||||
export type UpdateAppSettingsMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Update application settings (admin)
|
||||
*/
|
||||
export const useUpdateAppSettings = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAppSettings>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateAppSettingsBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateAppSettings>>,
|
||||
TError,
|
||||
{ data: BodyType<UpdateAppSettingsBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateAppSettingsMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Request a presigned URL for file upload
|
||||
*/
|
||||
|
||||
@@ -236,6 +236,43 @@ paths:
|
||||
"204":
|
||||
description: Deleted
|
||||
|
||||
# Settings
|
||||
/settings:
|
||||
get:
|
||||
operationId: getAppSettings
|
||||
tags: [settings]
|
||||
summary: Get application settings (public)
|
||||
responses:
|
||||
"200":
|
||||
description: Current settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppSettings"
|
||||
patch:
|
||||
operationId: updateAppSettings
|
||||
tags: [settings]
|
||||
summary: Update application settings (admin)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateAppSettingsBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppSettings"
|
||||
"400":
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# Storage
|
||||
/storage/uploads/request-url:
|
||||
post:
|
||||
@@ -1140,6 +1177,30 @@ components:
|
||||
- isRead
|
||||
- createdAt
|
||||
|
||||
AppSettings:
|
||||
type: object
|
||||
required: [siteNameAr, siteNameEn]
|
||||
properties:
|
||||
siteNameAr:
|
||||
type: string
|
||||
siteNameEn:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
UpdateAppSettingsBody:
|
||||
type: object
|
||||
properties:
|
||||
siteNameAr:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
siteNameEn:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
|
||||
RequestUploadUrlBody:
|
||||
type: object
|
||||
required: [name, size, contentType]
|
||||
|
||||
@@ -197,6 +197,41 @@ export const DeleteAppParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get application settings (public)
|
||||
*/
|
||||
export const GetAppSettingsResponse = zod.object({
|
||||
siteNameAr: zod.string(),
|
||||
siteNameEn: zod.string(),
|
||||
updatedAt: zod.coerce.date().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Update application settings (admin)
|
||||
*/
|
||||
export const updateAppSettingsBodySiteNameArMax = 200;
|
||||
|
||||
export const updateAppSettingsBodySiteNameEnMax = 200;
|
||||
|
||||
export const UpdateAppSettingsBody = zod.object({
|
||||
siteNameAr: zod
|
||||
.string()
|
||||
.min(1)
|
||||
.max(updateAppSettingsBodySiteNameArMax)
|
||||
.optional(),
|
||||
siteNameEn: zod
|
||||
.string()
|
||||
.min(1)
|
||||
.max(updateAppSettingsBodySiteNameEnMax)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const UpdateAppSettingsResponse = zod.object({
|
||||
siteNameAr: zod.string(),
|
||||
siteNameEn: zod.string(),
|
||||
updatedAt: zod.coerce.date().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Request a presigned URL for file upload
|
||||
*/
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./apps";
|
||||
export * from "./services";
|
||||
export * from "./conversations";
|
||||
export * from "./notifications";
|
||||
export * from "./settings";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pgTable, varchar, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const appSettingsTable = pgTable("app_settings", {
|
||||
id: integer("id").primaryKey().default(1),
|
||||
siteNameAr: varchar("site_name_ar", { length: 200 }).notNull().default("نظام TeaBoy"),
|
||||
siteNameEn: varchar("site_name_en", { length: 200 }).notNull().default("TeaBoy OS"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
});
|
||||
|
||||
export const updateAppSettingsSchema = createInsertSchema(appSettingsTable)
|
||||
.omit({ id: true, updatedAt: true })
|
||||
.partial();
|
||||
export type AppSettings = typeof appSettingsTable.$inferSelect;
|
||||
export type UpdateAppSettings = z.infer<typeof updateAppSettingsSchema>;
|
||||
@@ -1,2 +1,2 @@
|
||||
export { ObjectUploader } from "./ObjectUploader";
|
||||
export { useUpload } from "./use-upload";
|
||||
export { useUpload, type UploadResponse } from "./use-upload";
|
||||
|
||||
@@ -7,7 +7,7 @@ interface UploadMetadata {
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
export interface UploadResponse {
|
||||
uploadURL: string;
|
||||
objectPath: string;
|
||||
metadata: UploadMetadata;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user