@open-mercato/core 0.6.6-develop.5503.1.6cdc4dda5f → 0.6.6-develop.5509.1.006f4d4f24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/organization/index.js +2 -0
  3. package/dist/generated/entities/organization/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/auth/api/admin/nav.js +9 -0
  7. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  8. package/dist/modules/auth/lib/backendChrome.js +35 -2
  9. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  10. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  11. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  12. package/dist/modules/directory/api/organizations/route.js +7 -0
  13. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  14. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  15. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  16. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  17. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  18. package/dist/modules/directory/commands/organizations.js +8 -1
  19. package/dist/modules/directory/commands/organizations.js.map +2 -2
  20. package/dist/modules/directory/data/entities.js +3 -0
  21. package/dist/modules/directory/data/entities.js.map +2 -2
  22. package/dist/modules/directory/data/validators.js +9 -0
  23. package/dist/modules/directory/data/validators.js.map +2 -2
  24. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  25. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  26. package/generated/entities/organization/index.ts +1 -0
  27. package/generated/entity-fields-registry.ts +1 -0
  28. package/package.json +7 -7
  29. package/src/modules/auth/api/admin/nav.ts +9 -0
  30. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  31. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  32. package/src/modules/directory/api/organizations/route.ts +7 -0
  33. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  34. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  35. package/src/modules/directory/commands/organizations.ts +9 -1
  36. package/src/modules/directory/data/entities.ts +3 -0
  37. package/src/modules/directory/data/validators.ts +12 -0
  38. package/src/modules/directory/i18n/de.json +21 -0
  39. package/src/modules/directory/i18n/en.json +21 -0
  40. package/src/modules/directory/i18n/es.json +21 -0
  41. package/src/modules/directory/i18n/pl.json +21 -0
  42. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  43. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
@@ -0,0 +1,238 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { runWithCacheTenant } from '@open-mercato/cache'
5
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
8
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
9
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
+ import { Organization } from '@open-mercato/core/modules/directory/data/entities'
13
+ import { organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'
14
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
15
+ import '@open-mercato/core/modules/directory/commands/organizations'
16
+
17
+ export const metadata = {
18
+ GET: { requireAuth: true, requireFeatures: ['directory.organizations.view'] },
19
+ PUT: { requireAuth: true, requireFeatures: ['directory.organizations.manage'] },
20
+ }
21
+
22
+ const brandingResponseSchema = z.object({
23
+ organizationId: z.string().uuid(),
24
+ organizationName: z.string(),
25
+ tenantId: z.string().uuid(),
26
+ logoUrl: z.string().nullable(),
27
+ })
28
+
29
+ const brandingUpdateSchema = z.object({
30
+ logoUrl: organizationUpdateSchema.shape.logoUrl,
31
+ })
32
+
33
+ const errorSchema = z.object({
34
+ error: z.string(),
35
+ })
36
+
37
+ type RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>
38
+
39
+ function buildCommandContext(
40
+ container: RequestContainer,
41
+ auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,
42
+ req: Request,
43
+ organizationId: string,
44
+ tenantId: string,
45
+ ): CommandRuntimeContext {
46
+ return {
47
+ container,
48
+ auth,
49
+ organizationScope: {
50
+ selectedId: organizationId,
51
+ filterIds: [organizationId],
52
+ allowedIds: null,
53
+ tenantId,
54
+ },
55
+ selectedOrganizationId: organizationId,
56
+ organizationIds: [organizationId],
57
+ request: req,
58
+ }
59
+ }
60
+
61
+ async function resolveCurrentOrganization(req: Request) {
62
+ const { translate } = await resolveTranslations()
63
+ const auth = await getAuthFromRequest(req)
64
+ if (!auth?.sub) {
65
+ return {
66
+ response: NextResponse.json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 }),
67
+ }
68
+ }
69
+
70
+ const container = await createRequestContainer()
71
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
72
+ const organizationId = scope.selectedId ?? auth.orgId ?? null
73
+ const tenantId = scope.tenantId ?? auth.tenantId ?? null
74
+ if (!organizationId || !tenantId) {
75
+ return {
76
+ response: NextResponse.json(
77
+ {
78
+ error: translate(
79
+ 'directory.branding.errors.organizationRequired',
80
+ 'Select a single organization before changing sidebar branding.',
81
+ ),
82
+ },
83
+ { status: 400 },
84
+ ),
85
+ }
86
+ }
87
+
88
+ const em = container.resolve('em') as EntityManager
89
+ const organization = await findOneWithDecryption(
90
+ em,
91
+ Organization,
92
+ { id: organizationId, tenant: tenantId, deletedAt: null },
93
+ { populate: ['tenant'] },
94
+ { tenantId, organizationId },
95
+ )
96
+ if (!organization) {
97
+ return {
98
+ response: NextResponse.json(
99
+ { error: translate('directory.branding.errors.notFound', 'Organization not found') },
100
+ { status: 404 },
101
+ ),
102
+ }
103
+ }
104
+
105
+ return { auth, container, organization, organizationId, tenantId, translate }
106
+ }
107
+
108
+ function toResponsePayload(organization: Organization, tenantId: string) {
109
+ return {
110
+ organizationId: String(organization.id),
111
+ organizationName: organization.name,
112
+ tenantId,
113
+ logoUrl: organization.logoUrl ?? null,
114
+ }
115
+ }
116
+
117
+ async function invalidateSidebarBrandingCache(container: RequestContainer, organizationId: string, tenantId: string) {
118
+ try {
119
+ const cache = container.resolve('cache') as {
120
+ deleteByTags?: (tags: string[]) => Promise<unknown>
121
+ } | null
122
+ await runWithCacheTenant(tenantId, () =>
123
+ cache?.deleteByTags?.([
124
+ `nav:sidebar:organization:${organizationId}`,
125
+ `nav:sidebar:tenant:${tenantId}`,
126
+ ]),
127
+ )
128
+ } catch {
129
+ // Cache invalidation is best-effort; the persisted branding is the source of truth.
130
+ }
131
+ }
132
+
133
+ export async function GET(req: Request) {
134
+ const resolved = await resolveCurrentOrganization(req)
135
+ if ('response' in resolved) return resolved.response
136
+
137
+ return NextResponse.json(toResponsePayload(resolved.organization, resolved.tenantId))
138
+ }
139
+
140
+ export async function PUT(req: Request) {
141
+ const resolved = await resolveCurrentOrganization(req)
142
+ if ('response' in resolved) return resolved.response
143
+
144
+ let body: unknown
145
+ try {
146
+ body = await req.json()
147
+ } catch {
148
+ return NextResponse.json(
149
+ { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },
150
+ { status: 422 },
151
+ )
152
+ }
153
+ if (!body || typeof body !== 'object' || !Object.prototype.hasOwnProperty.call(body, 'logoUrl')) {
154
+ return NextResponse.json(
155
+ { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },
156
+ { status: 422 },
157
+ )
158
+ }
159
+
160
+ const parsed = brandingUpdateSchema.safeParse(body)
161
+ if (!parsed.success) {
162
+ return NextResponse.json(
163
+ {
164
+ error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.'),
165
+ issues: parsed.error.issues,
166
+ },
167
+ { status: 422 },
168
+ )
169
+ }
170
+
171
+ try {
172
+ const commandBus = resolved.container.resolve('commandBus') as CommandBus
173
+ const ctx = buildCommandContext(
174
+ resolved.container,
175
+ resolved.auth,
176
+ req,
177
+ resolved.organizationId,
178
+ resolved.tenantId,
179
+ )
180
+ const { result } = await commandBus.execute<Record<string, unknown>, Organization>(
181
+ 'directory.organizations.update',
182
+ {
183
+ input: {
184
+ id: resolved.organizationId,
185
+ tenantId: resolved.tenantId,
186
+ logoUrl: parsed.data.logoUrl ?? null,
187
+ },
188
+ ctx,
189
+ },
190
+ )
191
+ await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)
192
+ return NextResponse.json(toResponsePayload(result, resolved.tenantId))
193
+ } catch (err) {
194
+ if (isCrudHttpError(err)) {
195
+ return NextResponse.json(err.body, { status: err.status })
196
+ }
197
+ console.error('directory.organization-branding.update failed', err)
198
+ return NextResponse.json(
199
+ { error: resolved.translate('directory.branding.errors.save', 'Failed to update organization branding.') },
200
+ { status: 400 },
201
+ )
202
+ }
203
+ }
204
+
205
+ export const openApi: OpenApiRouteDoc = {
206
+ tag: 'Directory',
207
+ summary: 'Current organization branding',
208
+ methods: {
209
+ GET: {
210
+ summary: 'Read sidebar branding for the selected organization',
211
+ description: 'Returns the logo URL used by the backend sidebar for the currently selected organization.',
212
+ responses: [
213
+ { status: 200, description: 'Organization branding', schema: brandingResponseSchema },
214
+ ],
215
+ errors: [
216
+ { status: 400, description: 'A concrete organization scope is required', schema: errorSchema },
217
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
218
+ { status: 404, description: 'Organization not found', schema: errorSchema },
219
+ ],
220
+ },
221
+ PUT: {
222
+ summary: 'Update sidebar branding for the selected organization',
223
+ description: 'Stores an external image URL or an internal attachment image URL as the selected organization logo.',
224
+ requestBody: {
225
+ contentType: 'application/json',
226
+ schema: brandingUpdateSchema,
227
+ },
228
+ responses: [
229
+ { status: 200, description: 'Updated organization branding', schema: brandingResponseSchema },
230
+ ],
231
+ errors: [
232
+ { status: 400, description: 'Save failed', schema: errorSchema },
233
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
234
+ { status: 422, description: 'Invalid logo URL', schema: errorSchema },
235
+ ],
236
+ },
237
+ },
238
+ }
@@ -250,6 +250,7 @@ export async function GET(req: Request) {
250
250
  const items = orgs.map((org) => ({
251
251
  id: stringId(org.id),
252
252
  name: org.name,
253
+ logoUrl: org.logoUrl ?? null,
253
254
  parentId: org.parentId ?? null,
254
255
  tenantId: tenantId,
255
256
  isActive: !!org.isActive,
@@ -341,8 +342,10 @@ export async function GET(req: Request) {
341
342
  }
342
343
 
343
344
  const slugByOrgId = new Map<string, string | null>()
345
+ const logoUrlByOrgId = new Map<string, string | null>()
344
346
  for (const org of allOrgs) {
345
347
  slugByOrgId.set(String(org.id), org.slug ?? null)
348
+ logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
346
349
  }
347
350
 
348
351
  const tenantIds = Array.from(byTenant.keys())
@@ -426,6 +429,7 @@ export async function GET(req: Request) {
426
429
  id: node.id,
427
430
  name: node.name,
428
431
  slug: slugByOrgId.get(recordId) ?? null,
432
+ logoUrl: logoUrlByOrgId.get(recordId) ?? null,
429
433
  tenantId: tid,
430
434
  tenantName: tenantNameMap[tid] ?? tid,
431
435
  parentId: node.parentId,
@@ -467,9 +471,11 @@ export async function GET(req: Request) {
467
471
  const orgs = await em.find(Organization, orgListFilter, { orderBy: { name: 'ASC' } })
468
472
  const hierarchy = computeHierarchyForOrganizations(orgs, tenantId)
469
473
  const slugByOrgId = new Map<string, string | null>()
474
+ const logoUrlByOrgId = new Map<string, string | null>()
470
475
  const updatedAtByOrgId = new Map<string, string | null>()
471
476
  for (const org of orgs) {
472
477
  slugByOrgId.set(String(org.id), org.slug ?? null)
478
+ logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
473
479
  updatedAtByOrgId.set(String(org.id), org.updatedAt instanceof Date ? org.updatedAt.toISOString() : null)
474
480
  }
475
481
 
@@ -533,6 +539,7 @@ export async function GET(req: Request) {
533
539
  id: node.id,
534
540
  name: node.name,
535
541
  slug: slugByOrgId.get(recordId) ?? null,
542
+ logoUrl: logoUrlByOrgId.get(recordId) ?? null,
536
543
  updatedAt: updatedAtByOrgId.get(recordId) ?? null,
537
544
  tenantId: node.tenantId,
538
545
  tenantName,
@@ -0,0 +1,24 @@
1
+ import React from 'react'
2
+
3
+ const brandingIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
7
+ React.createElement('circle', { cx: 8, cy: 10, r: 1.5 }),
8
+ React.createElement('path', { d: 'm21 15-5-5L5 21' }),
9
+ )
10
+
11
+ export const metadata = {
12
+ requireAuth: true,
13
+ requireFeatures: ['directory.organizations.manage'],
14
+ pageTitle: 'Organization branding',
15
+ pageTitleKey: 'directory.branding.nav',
16
+ pageGroup: 'Directory',
17
+ pageGroupKey: 'settings.sections.directory',
18
+ pageOrder: 0,
19
+ icon: brandingIcon,
20
+ pageContext: 'settings' as const,
21
+ breadcrumb: [{ label: 'Organization branding', labelKey: 'directory.branding.nav' }],
22
+ }
23
+
24
+ export default metadata
@@ -0,0 +1,248 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
5
+ import { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'
6
+ import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
7
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
8
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
+ import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { Button } from '@open-mercato/ui/primitives/button'
12
+ import { Input } from '@open-mercato/ui/primitives/input'
13
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+
15
+ type BrandingPayload = {
16
+ organizationId: string
17
+ organizationName: string
18
+ tenantId: string
19
+ logoUrl: string | null
20
+ }
21
+
22
+ type UploadPayload = {
23
+ ok: true
24
+ item: {
25
+ id: string
26
+ url: string
27
+ thumbnailUrl?: string
28
+ }
29
+ }
30
+
31
+ const BRANDING_API = '/api/directory/organization-branding'
32
+ const BRANDING_ENTITY_ID = 'directory.organization'
33
+
34
+ export default function OrganizationBrandingPage() {
35
+ const t = useT()
36
+ const queryClient = useQueryClient()
37
+ const [logoUrl, setLogoUrl] = React.useState('')
38
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
39
+ const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)
40
+ const [saving, setSaving] = React.useState(false)
41
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null)
42
+ const { runMutation } = useGuardedMutation({
43
+ contextId: 'directory.organization-branding',
44
+ blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),
45
+ })
46
+
47
+ const { data, isLoading, error } = useQuery<BrandingPayload>({
48
+ queryKey: ['directory-organization-branding'],
49
+ queryFn: () => readApiResultOrThrow<BrandingPayload>(
50
+ BRANDING_API,
51
+ undefined,
52
+ { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },
53
+ ),
54
+ })
55
+
56
+ React.useEffect(() => {
57
+ setLogoUrl(data?.logoUrl ?? '')
58
+ setSelectedFile(null)
59
+ }, [data?.logoUrl])
60
+
61
+ React.useEffect(() => {
62
+ if (!selectedFile || typeof URL === 'undefined') {
63
+ setFilePreviewUrl(null)
64
+ return
65
+ }
66
+ const nextPreviewUrl = URL.createObjectURL(selectedFile)
67
+ setFilePreviewUrl(nextPreviewUrl)
68
+ return () => URL.revokeObjectURL(nextPreviewUrl)
69
+ }, [selectedFile])
70
+
71
+ const currentPreviewUrl = filePreviewUrl ?? logoUrl
72
+
73
+ const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {
74
+ if (!selectedFile) return null
75
+ const form = new FormData()
76
+ form.set('entityId', BRANDING_ENTITY_ID)
77
+ form.set('recordId', organizationId)
78
+ form.set('file', selectedFile)
79
+ form.set('tags', JSON.stringify(['organization-logo']))
80
+
81
+ const upload = await readApiResultOrThrow<UploadPayload>(
82
+ '/api/attachments',
83
+ {
84
+ method: 'POST',
85
+ body: form,
86
+ },
87
+ { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },
88
+ )
89
+ return upload?.item.thumbnailUrl ?? upload?.item.url ?? null
90
+ }, [selectedFile, t])
91
+
92
+ const saveBranding = React.useCallback(async (nextLogoUrl?: string, options?: { skipUpload?: boolean }) => {
93
+ if (!data) return
94
+ const shouldUpload = Boolean(selectedFile && !options?.skipUpload)
95
+ setSaving(true)
96
+ try {
97
+ await runMutation({
98
+ operation: async () => {
99
+ const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null
100
+ const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()
101
+ // optimistic-lock-exempt: selected organization branding uses a scoped command endpoint without an exposed updatedAt token.
102
+ const response = await apiCallOrThrow<BrandingPayload>(
103
+ BRANDING_API,
104
+ {
105
+ method: 'PUT',
106
+ headers: { 'content-type': 'application/json' },
107
+ body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),
108
+ },
109
+ { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },
110
+ )
111
+ return response.result
112
+ },
113
+ context: {
114
+ entityId: BRANDING_ENTITY_ID,
115
+ recordId: data.organizationId,
116
+ operation: 'update-branding',
117
+ },
118
+ mutationPayload: {
119
+ organizationId: data.organizationId,
120
+ logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,
121
+ hasUpload: shouldUpload,
122
+ },
123
+ })
124
+ await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })
125
+ window.dispatchEvent(new Event('om:refresh-sidebar'))
126
+ setSelectedFile(null)
127
+ if (fileInputRef.current) fileInputRef.current.value = ''
128
+ flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')
129
+ } catch (err: unknown) {
130
+ const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')
131
+ const message = err instanceof Error ? err.message : fallback
132
+ flash(message, 'error')
133
+ } finally {
134
+ setSaving(false)
135
+ }
136
+ }, [data, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])
137
+
138
+ const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
139
+ event.preventDefault()
140
+ void saveBranding()
141
+ }, [saveBranding])
142
+
143
+ if (isLoading) {
144
+ return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />
145
+ }
146
+
147
+ if (error || !data) {
148
+ return (
149
+ <ErrorMessage
150
+ label={t('directory.branding.errors.load', 'Failed to load organization branding')}
151
+ description={error instanceof Error ? error.message : undefined}
152
+ />
153
+ )
154
+ }
155
+
156
+ return (
157
+ <Page>
158
+ <PageHeader
159
+ title={t('directory.branding.title', 'Organization branding')}
160
+ description={t(
161
+ 'directory.branding.description',
162
+ 'Set the logo used in the backend sidebar for the currently selected organization.',
163
+ )}
164
+ />
165
+ <PageBody>
166
+ <form className="space-y-5" onSubmit={handleSubmit}>
167
+ <div className="grid gap-5 lg:grid-cols-[260px_1fr]">
168
+ <div className="space-y-3">
169
+ <div className="flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30">
170
+ {currentPreviewUrl ? (
171
+ <img
172
+ src={currentPreviewUrl}
173
+ alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}
174
+ className="h-full w-full object-contain"
175
+ />
176
+ ) : (
177
+ <ImagePlus className="size-10 text-muted-foreground" aria-hidden />
178
+ )}
179
+ </div>
180
+ <p className="text-sm font-medium text-foreground">{data.organizationName}</p>
181
+ <p className="text-xs text-muted-foreground">
182
+ {t('directory.branding.currentScope', 'Current organization')}
183
+ </p>
184
+ </div>
185
+
186
+ <div className="space-y-4">
187
+ <div className="space-y-2">
188
+ <label htmlFor="organization-logo-file" className="text-sm font-medium">
189
+ {t('directory.branding.file.label', 'Upload logo')}
190
+ </label>
191
+ <Input
192
+ ref={fileInputRef}
193
+ id="organization-logo-file"
194
+ type="file"
195
+ accept="image/png,image/jpeg,image/webp,image/svg+xml"
196
+ onChange={(event) => {
197
+ const file = event.currentTarget.files?.[0]
198
+ if (!file) return
199
+ setSelectedFile(file)
200
+ }}
201
+ />
202
+ <p className="text-xs text-muted-foreground">
203
+ {t('directory.branding.file.hint', 'PNG, JPG, WebP, or SVG works best. Uploaded files are stored as organization attachments.')}
204
+ </p>
205
+ </div>
206
+
207
+ <div className="space-y-2">
208
+ <label htmlFor="organization-logo-url" className="text-sm font-medium">
209
+ {t('directory.branding.url.label', 'Logo URL')}
210
+ </label>
211
+ <Input
212
+ id="organization-logo-url"
213
+ value={logoUrl}
214
+ onChange={(event) => setLogoUrl(event.currentTarget.value)}
215
+ placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}
216
+ />
217
+ <p className="text-xs text-muted-foreground">
218
+ {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}
219
+ </p>
220
+ </div>
221
+
222
+ <div className="flex flex-wrap items-center gap-2">
223
+ <Button type="submit" disabled={saving}>
224
+ {saving ? <Loader2 className="mr-2 size-4 animate-spin" aria-hidden /> : <Save className="mr-2 size-4" aria-hidden />}
225
+ {t('directory.branding.actions.save', 'Save branding')}
226
+ </Button>
227
+ <Button
228
+ type="button"
229
+ variant="outline"
230
+ disabled={saving}
231
+ onClick={() => {
232
+ setSelectedFile(null)
233
+ if (fileInputRef.current) fileInputRef.current.value = ''
234
+ setLogoUrl('')
235
+ void saveBranding('', { skipUpload: true })
236
+ }}
237
+ >
238
+ <RotateCcw className="mr-2 size-4" aria-hidden />
239
+ {t('directory.branding.actions.reset', 'Use default logo')}
240
+ </Button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </form>
245
+ </PageBody>
246
+ </Page>
247
+ )
248
+ }
@@ -84,6 +84,7 @@ type OrganizationUndoSnapshot = {
84
84
  tenantId: string | null
85
85
  name: string
86
86
  slug?: string | null
87
+ logoUrl?: string | null
87
88
  isActive: boolean
88
89
  parentId: string | null
89
90
  childParents: ChildParentSnapshot[]
@@ -120,6 +121,7 @@ function serializeOrganization(entity: Organization, custom?: Record<string, unk
120
121
  tenantId: resolveTenantIdFromEntity(entity),
121
122
  name: entity.name,
122
123
  slug: entity.slug ?? null,
124
+ logoUrl: entity.logoUrl ?? null,
123
125
  isActive: !!entity.isActive,
124
126
  parentId: entity.parentId ?? null,
125
127
  ancestorIds: Array.isArray(entity.ancestorIds) ? [...entity.ancestorIds] : [],
@@ -144,6 +146,7 @@ function captureOrganizationSnapshots(
144
146
  tenantId,
145
147
  name: entity.name,
146
148
  slug: entity.slug ?? null,
149
+ logoUrl: entity.logoUrl ?? null,
147
150
  isActive: !!entity.isActive,
148
151
  parentId: entity.parentId ?? null,
149
152
  childParents: (childParents ?? []).map((entry) => ({
@@ -303,6 +306,7 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
303
306
  tenant: tenantRef,
304
307
  name: parsed.name,
305
308
  slug,
309
+ logoUrl: parsed.logoUrl ?? null,
306
310
  isActive: parsed.isActive ?? true,
307
311
  parentId,
308
312
  },
@@ -429,6 +433,7 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
429
433
  existing.deletedAt = null
430
434
  existing.name = after.name
431
435
  if (after.slug !== undefined) existing.slug = after.slug ?? null
436
+ if (after.logoUrl !== undefined) existing.logoUrl = after.logoUrl ?? null
432
437
  existing.isActive = after.isActive
433
438
  existing.parentId = after.parentId
434
439
  await em.flush()
@@ -440,6 +445,7 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
440
445
  id: after.id,
441
446
  name: after.name,
442
447
  slug: after.slug ?? null,
448
+ logoUrl: after.logoUrl ?? null,
443
449
  tenant: em.getReference(Tenant, tenantId),
444
450
  isActive: after.isActive,
445
451
  parentId: after.parentId,
@@ -564,6 +570,7 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
564
570
  apply: (entity) => {
565
571
  if (parsed.name !== undefined) entity.name = parsed.name
566
572
  if (resolvedSlug !== undefined) entity.slug = resolvedSlug
573
+ if (parsed.logoUrl !== undefined) entity.logoUrl = parsed.logoUrl ?? null
567
574
  if (parsed.isActive !== undefined) entity.isActive = parsed.isActive
568
575
  entity.parentId = parentId
569
576
  },
@@ -630,7 +637,7 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
630
637
  organizationId: String(result.id),
631
638
  })
632
639
  const after = serializeOrganization(result, custom)
633
- const changes = buildChanges(beforeRecord, after as Record<string, unknown>, ['name', 'slug', 'isActive', 'parentId'])
640
+ const changes = buildChanges(beforeRecord, after as Record<string, unknown>, ['name', 'slug', 'logoUrl', 'isActive', 'parentId'])
634
641
  const customDiff = diffCustomFieldChanges(beforeRecord?.custom, custom)
635
642
  for (const [key, diff] of Object.entries(customDiff)) {
636
643
  changes[`cf_${key}`] = diff
@@ -667,6 +674,7 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
667
674
  apply: (entity) => {
668
675
  entity.name = before.name
669
676
  if (before.slug !== undefined) entity.slug = before.slug
677
+ if (before.logoUrl !== undefined) entity.logoUrl = before.logoUrl ?? null
670
678
  entity.isActive = before.isActive
671
679
  entity.parentId = before.parentId
672
680
  },
@@ -40,6 +40,9 @@ export class Organization {
40
40
  @Property({ type: 'text', nullable: true })
41
41
  slug?: string | null
42
42
 
43
+ @Property({ name: 'logo_url', type: 'text', nullable: true })
44
+ logoUrl?: string | null
45
+
43
46
  @Property({ name: 'is_active', type: 'boolean', default: true })
44
47
  isActive: boolean = true
45
48
 
@@ -12,11 +12,22 @@ export const tenantUpdateSchema = z.object({
12
12
  })
13
13
 
14
14
  const slugField = z.string().trim().toLowerCase().regex(/^[a-z0-9\-_]+$/).max(150).optional().nullable()
15
+ const logoUrlField = z
16
+ .union([
17
+ z.string().trim().url().max(2048).refine(
18
+ (value) => value.startsWith('https://') || value.startsWith('http://'),
19
+ { message: 'Logo URL must use http or https.' },
20
+ ),
21
+ z.string().trim().regex(/^\/api\/attachments\/(?:image|file)\/[A-Za-z0-9%_.~/?=&-]+$/).max(2048),
22
+ ])
23
+ .optional()
24
+ .nullable()
15
25
 
16
26
  export const organizationCreateSchema = z.object({
17
27
  tenantId: z.string().uuid().optional(),
18
28
  name: z.string().min(1).max(200),
19
29
  slug: slugField,
30
+ logoUrl: logoUrlField,
20
31
  isActive: z.boolean().optional(),
21
32
  parentId: z.string().uuid().nullable().optional(),
22
33
  childIds: z.array(z.string().uuid()).optional(),
@@ -27,6 +38,7 @@ export const organizationUpdateSchema = z.object({
27
38
  tenantId: z.string().uuid().optional(),
28
39
  name: z.string().min(1).max(200).optional(),
29
40
  slug: slugField,
41
+ logoUrl: logoUrlField,
30
42
  isActive: z.boolean().optional(),
31
43
  parentId: z.string().uuid().nullable().optional(),
32
44
  childIds: z.array(z.string().uuid()).optional(),