@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/organization/index.js +2 -0
- package/dist/generated/entities/organization/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/api/admin/nav.js +9 -0
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +35 -2
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +214 -0
- package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
- package/dist/modules/directory/api/organizations/route.js +7 -0
- package/dist/modules/directory/api/organizations/route.js.map +3 -3
- package/dist/modules/directory/backend/directory/branding/page.js +214 -0
- package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
- package/dist/modules/directory/commands/organizations.js +8 -1
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/data/entities.js +3 -0
- package/dist/modules/directory/data/entities.js.map +2 -2
- package/dist/modules/directory/data/validators.js +9 -0
- package/dist/modules/directory/data/validators.js.map +2 -2
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
- package/generated/entities/organization/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/auth/api/admin/nav.ts +9 -0
- package/src/modules/auth/lib/backendChrome.tsx +37 -1
- package/src/modules/directory/api/organization-branding/route.ts +238 -0
- package/src/modules/directory/api/organizations/route.ts +7 -0
- package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
- package/src/modules/directory/commands/organizations.ts +9 -1
- package/src/modules/directory/data/entities.ts +3 -0
- package/src/modules/directory/data/validators.ts +12 -0
- package/src/modules/directory/i18n/de.json +21 -0
- package/src/modules/directory/i18n/en.json +21 -0
- package/src/modules/directory/i18n/es.json +21 -0
- package/src/modules/directory/i18n/pl.json +21 -0
- package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
- 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(),
|