Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Every non-trivial PR adds a bullet under `## [Unreleased]`. Trivial edits (typos

## [Unreleased]

### Added
- Workspace branding in **Workspace settings** (organizations): owners/admins can upload a **logo** image and set a **main** and **auxiliary** brand color. The logo replaces the Lucide icon wherever the workspace is shown (sidebar switcher, workspace header, workspace picker) and falls back to the icon when unset; the two colors override the `--primary` / `--accent` design tokens app-wide (plus their `--ring` / `--sidebar-*` mirrors, with a contrast-picked foreground), applied in both light and dark. Logo is stored inline as a size-capped base64 data URL on the workspace record and travels with the existing `namespaces.get` / `users.me` payloads — no new upload/serving route; colors are `#rrggbb` hex on the same record. All three flow through the existing `PATCH /api/namespaces/:handle` (empty string clears), and the base64 logo is kept out of the audit snapshot.

### Fixed
- `mediforce-fullstack` no longer re-triages the same declined (`fullstack:manual`) issue on every tick: the "re-judge on edit" check compared `issue.updated_at` against the manual label event, but `updated_at` is bumped by *any* activity — including a human adding an unrelated label — which latched issues like #425 into an endless re-triage loop. `fetch-candidates` now treats an issue as edited only when `updated_at` outruns the newest recorded issue *event* (label/assignment changes create an event at the same instant, so they are ignored; body edits and comments are the only changes that don't, so they still re-judge), and `apply-verdicts` re-stamps the label when re-declining an already-manual issue so a genuine edit re-judges exactly once instead of looping.
- Runs stranded in `running` are now recovered by the cron heartbeat. When a run's auto-runner request dies mid-step (deploy, crash, or request timeout — the whole step loop runs inside one long-lived `/run` request), the run is left `status: running` with no live driver. The heartbeat's sweeps only looked at `paused` runs (resume `waiting_for_timer`, fail `paused`/null-reason orphans), so a stranded `running` run was invisible to every sweep and sat at its current step forever — e.g. a `mediforce-fullstack` run wedged at `arm-timer` for 2h+ while its PR's CI had long since gone green. The heartbeat now also sweeps `running` runs idle past their current step's own configured timeout plus a grace margin (falling back to a 45-minute default when the step can't be resolved), so a step that legitimately runs longer than the default is never mistaken for stranded, and re-kicks them; the re-kick is idempotent — `/run` returns 409 while a live driver still holds the per-process lock, so only genuinely driverless runs advance [#892](https://github.com/Appsilon/mediforce/issues/892).
Expand Down
20 changes: 20 additions & 0 deletions packages/platform-api/src/contract/__tests__/namespaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ describe('UpdateNamespace schemas', () => {
it('UpdateNamespaceInputSchema accepts bio: "" to clear', () => {
expect(UpdateNamespaceInputSchema.safeParse({ handle: 'acme', bio: '' }).success).toBe(true);
});

it('accepts brand colors as #rrggbb hex and "" to clear', () => {
expect(UpdateNamespaceBodySchema.safeParse({ brandPrimaryColor: '#0d9488' }).success).toBe(true);
expect(UpdateNamespaceBodySchema.safeParse({ brandAccentColor: '#F59E0B' }).success).toBe(true);
expect(UpdateNamespaceBodySchema.safeParse({ brandPrimaryColor: '' }).success).toBe(true);
});

it('rejects malformed brand colors', () => {
expect(UpdateNamespaceBodySchema.safeParse({ brandPrimaryColor: 'teal' }).success).toBe(false);
expect(UpdateNamespaceBodySchema.safeParse({ brandPrimaryColor: '#fff' }).success).toBe(false);
expect(UpdateNamespaceBodySchema.safeParse({ brandAccentColor: '0d9488' }).success).toBe(false);
});

it('accepts a base64 image data URL logo and "" to clear, rejects non-images', () => {
const logo = 'data:image/png;base64,iVBORw0KGgo=';
expect(UpdateNamespaceBodySchema.safeParse({ logo }).success).toBe(true);
expect(UpdateNamespaceBodySchema.safeParse({ logo: '' }).success).toBe(true);
expect(UpdateNamespaceBodySchema.safeParse({ logo: 'https://example.com/a.png' }).success).toBe(false);
expect(UpdateNamespaceBodySchema.safeParse({ logo: 'data:text/html;base64,PHNjcmlwdD4=' }).success).toBe(false);
});
});

describe('UpdateNamespaceMemberRoleBodySchema', () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/platform-api/src/contract/namespaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
HandleSchema,
NamespaceSchema,
NamespaceMemberSchema,
BrandColorSchema,
WorkspaceLogoSchema,
} from '@mediforce/platform-core';

export const GetNamespaceInputSchema = z.object({ handle: HandleSchema });
Expand Down Expand Up @@ -37,10 +39,19 @@ const UpdateNamespaceFieldsSchema = z.object({
displayName: z.string().min(1).max(128).optional(),
bio: z.string().max(2048).optional(),
icon: z.string().min(1).max(64).optional(),
logo: WorkspaceLogoSchema.optional(),
brandPrimaryColor: BrandColorSchema.optional(),
brandAccentColor: BrandColorSchema.optional(),
});
const atLeastOneUpdateField = (v: z.infer<typeof UpdateNamespaceFieldsSchema>): boolean =>
v.displayName !== undefined || v.bio !== undefined || v.icon !== undefined;
const atLeastOneUpdateFieldMessage = 'At least one of displayName, bio, icon must be provided';
v.displayName !== undefined ||
v.bio !== undefined ||
v.icon !== undefined ||
v.logo !== undefined ||
v.brandPrimaryColor !== undefined ||
v.brandAccentColor !== undefined;
const atLeastOneUpdateFieldMessage =
'At least one of displayName, bio, icon, logo, brandPrimaryColor, brandAccentColor must be provided';

export const UpdateNamespaceBodySchema = UpdateNamespaceFieldsSchema.refine(
atLeastOneUpdateField,
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-api/src/contract/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const MeNamespaceSchema = z.object({
role: z.enum(['owner', 'admin', 'member']),
avatarUrl: z.string().url().optional(),
icon: z.string().optional(),
logo: z.string().optional(),
brandPrimaryColor: z.string().optional(),
brandAccentColor: z.string().optional(),
Comment on lines 25 to +29
});

export const GetMeOutputSchema = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ describe('updateNamespace handler', () => {
expect(namespaceRepo.namespaces.get(HANDLE)?.bio).toBe('');
});

it('updates logo and brand colors, returns the entity-echo', async () => {
const scope = createTestScope({ namespaceRepo, auditRepo, caller: ownerCaller });
const logo = 'data:image/png;base64,iVBORw0KGgo=';
const result = await updateNamespace(
{ handle: HANDLE, logo, brandPrimaryColor: '#0d9488', brandAccentColor: '#f59e0b' },
scope,
);
expect(result.namespace).toMatchObject({
logo,
brandPrimaryColor: '#0d9488',
brandAccentColor: '#f59e0b',
});
expect(namespaceRepo.namespaces.get(HANDLE)?.logo).toBe(logo);
});

it('clears logo and brand colors when passed empty strings', async () => {
const scope = createTestScope({ namespaceRepo, auditRepo, caller: ownerCaller });
await updateNamespace(
{ handle: HANDLE, logo: 'data:image/png;base64,iVBORw0KGgo=', brandPrimaryColor: '#0d9488' },
scope,
);
await updateNamespace({ handle: HANDLE, logo: '', brandPrimaryColor: '' }, scope);
expect(namespaceRepo.namespaces.get(HANDLE)?.logo).toBe('');
expect(namespaceRepo.namespaces.get(HANDLE)?.brandPrimaryColor).toBe('');
});

it('does not dump the base64 logo into the audit snapshot', async () => {
const scope = createTestScope({ namespaceRepo, auditRepo, caller: ownerCaller });
const logo = `data:image/png;base64,${'A'.repeat(4096)}`;
await updateNamespace({ handle: HANDLE, logo }, scope);
const event = auditRepo.getAll().find((e) => e.action === 'namespace.updated');
expect(event?.inputSnapshot).toMatchObject({ handle: HANDLE, logo: 'updated' });
expect(JSON.stringify(event?.inputSnapshot).length).toBeLessThan(200);
});

it('admins may edit (not just owners)', async () => {
const scope = createTestScope({ namespaceRepo, auditRepo, caller: adminCaller });
await expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import type {
} from '../../contract/namespaces';

/**
* Edit workspace `displayName`, `bio`, `icon`. Owner/admin only.
* Two-state semantics: undefined leaves the field untouched, any string
* overwrites it (empty string is the cleared state for `bio`).
* Edit workspace `displayName`, `bio`, `icon`, `logo`, and brand colors.
* Owner/admin only. Two-state semantics: undefined leaves the field untouched,
* any string overwrites it (empty string is the cleared state for `bio`, `logo`,
* and the brand colors — cleared branding falls back to the icon + default
* palette).
*/
export async function updateNamespace(
input: UpdateNamespaceInput,
Expand All @@ -37,6 +39,9 @@ export async function updateNamespace(
const updates: NamespaceUpdates = {
...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
...(input.icon !== undefined ? { icon: input.icon } : {}),
...(input.logo !== undefined ? { logo: input.logo } : {}),
...(input.brandPrimaryColor !== undefined ? { brandPrimaryColor: input.brandPrimaryColor } : {}),
...(input.brandAccentColor !== undefined ? { brandAccentColor: input.brandAccentColor } : {}),
...(input.bio !== undefined ? { bio: input.bio } : {}),
};

Expand All @@ -45,10 +50,15 @@ export async function updateNamespace(
const namespace = await scope.workspaces.getNamespace(input.handle);
if (namespace === null) throw new NotFoundError(`Namespace "${input.handle}" not found`);

// The logo is a multi-KB base64 data URL; record only whether it was set or
// cleared so the audit snapshot stays small.
const { logo, ...auditableUpdates } = updates;
const logoSnapshot = logo === undefined ? {} : { logo: logo === '' ? 'cleared' : 'updated' };

await emitAudit(scope.system.audit, scope.caller, {
action: 'namespace.updated',
description: `Namespace '${input.handle}' updated`,
inputSnapshot: { handle: input.handle, ...updates },
inputSnapshot: { handle: input.handle, ...auditableUpdates, ...logoSnapshot },
outputSnapshot: { handle: namespace.handle, displayName: namespace.displayName },
basis: 'Owner/admin edited workspace via API',
entityType: 'namespace',
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-api/src/handlers/users/get-me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export async function getMe(input: GetMeInput, scope: CallerScope): Promise<GetM
role: roleByHandle.get(n.handle) ?? 'owner',
...(n.avatarUrl !== undefined ? { avatarUrl: n.avatarUrl } : {}),
...(n.icon !== undefined ? { icon: n.icon } : {}),
...(n.logo !== undefined ? { logo: n.logo } : {}),
...(n.brandPrimaryColor !== undefined ? { brandPrimaryColor: n.brandPrimaryColor } : {}),
...(n.brandAccentColor !== undefined ? { brandAccentColor: n.brandAccentColor } : {}),
}));

return {
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export {
NamespaceSchema,
NamespaceMemberSchema,
NamespaceMembershipSchema,
BrandColorSchema,
WorkspaceLogoSchema,
WORKSPACE_LOGO_MAX_CHARS,
HandleSchema,
HANDLE_REGEX,
HANDLE_MAX_LENGTH,
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-core/src/interfaces/namespace-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import type { Namespace, NamespaceMember, NamespaceMembership } from '../schemas
export interface NamespaceUpdates {
readonly displayName?: string;
readonly icon?: string;
readonly logo?: string;
readonly brandPrimaryColor?: string;
readonly brandAccentColor?: string;
readonly bio?: string;
readonly avatarUrl?: string;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-core/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ export {
NamespaceSchema,
NamespaceMemberSchema,
NamespaceMembershipSchema,
BrandColorSchema,
WorkspaceLogoSchema,
WORKSPACE_LOGO_MAX_CHARS,
type NamespaceType,
type Namespace,
type NamespaceMember,
Expand Down
30 changes: 30 additions & 0 deletions packages/platform-core/src/schemas/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,42 @@ import { z } from 'zod';

export const NamespaceTypeSchema = z.enum(['personal', 'organization']);

/**
* A workspace brand color: a 6-digit hex string (`#rrggbb`), or `""` to signal
* "cleared, fall back to the platform default" — the same two-state convention
* `bio` uses. The UI converts the hex to an HSL triple at render time to
* override the `--primary` / `--accent` design tokens.
*/
export const BrandColorSchema = z
.string()
.regex(/^(#[0-9a-fA-F]{6})?$/, 'must be a hex color like #0d9488');

/**
* A workspace logo, stored inline as a base64 `data:` image URL (or `""` to
* clear). Kept on the workspace record rather than the blob store so it travels
* with the already-authenticated `namespaces.get` / `users.me` payloads and
* renders via a plain `<img src>` — no separate authenticated fetch. The
* ~256 KiB char cap keeps that payload small; logos should be optimised
* (SVG/PNG) assets, not photos.
*/
Comment on lines +15 to +22
export const WORKSPACE_LOGO_MAX_CHARS = 512 * 1024;
export const WorkspaceLogoSchema = z
.string()
.max(WORKSPACE_LOGO_MAX_CHARS, 'logo image is too large')
.refine(
(value) => value === '' || /^data:image\/(png|jpeg|jpg|svg\+xml|webp|gif);base64,/.test(value),
'logo must be a base64 image data URL',
);

export const NamespaceSchema = z.object({
handle: z.string().min(1),
type: NamespaceTypeSchema,
displayName: z.string().min(1),
avatarUrl: z.string().url().optional(),
icon: z.string().optional(),
logo: WorkspaceLogoSchema.optional(),
brandPrimaryColor: BrandColorSchema.optional(),
brandAccentColor: BrandColorSchema.optional(),
linkedUserId: z.string().optional(),
bio: z.string().optional(),
createdAt: z.string().datetime(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ function contract(name: string, factory: () => Promise<NamespaceRepository>) {
displayName: 'Full NS',
avatarUrl: 'https://example.com/a.png',
icon: 'rocket',
logo: 'data:image/png;base64,iVBORw0KGgo=',
brandPrimaryColor: '#0d9488',
brandAccentColor: '#f59e0b',
linkedUserId: 'user-99',
bio: 'A whole namespace.',
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Workspace branding: per-workspace logo image + main/auxiliary brand colors.
--
-- `logo` stores an optimised image inline as a base64 `data:` URL so it travels
-- with the already-authenticated namespace payloads and renders via a plain
-- `<img src>` (Zod caps it at ~512 KiB of characters). `brand_primary_color` /
-- `brand_accent_color` are `#rrggbb` hex strings the UI converts to HSL triples
-- to override the `--primary` / `--accent` design tokens app-wide.
--
-- All three are nullable — existing workspaces keep the Lucide icon + platform
-- default palette until branding is set.

ALTER TABLE "workspaces" ADD COLUMN "logo" text;
ALTER TABLE "workspaces" ADD COLUMN "brand_primary_color" text;
ALTER TABLE "workspaces" ADD COLUMN "brand_accent_color" text;

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export class PostgresNamespaceRepository implements NamespaceRepository {
displayName: parsed.displayName,
avatarUrl: parsed.avatarUrl ?? null,
icon: parsed.icon ?? null,
logo: parsed.logo ?? null,
brandPrimaryColor: parsed.brandPrimaryColor ?? null,
brandAccentColor: parsed.brandAccentColor ?? null,
linkedUserId: parsed.linkedUserId ?? null,
bio: parsed.bio ?? null,
createdAt: new Date(parsed.createdAt),
Expand All @@ -65,6 +68,9 @@ export class PostgresNamespaceRepository implements NamespaceRepository {
displayName: parsedNs.displayName,
avatarUrl: parsedNs.avatarUrl ?? null,
icon: parsedNs.icon ?? null,
logo: parsedNs.logo ?? null,
brandPrimaryColor: parsedNs.brandPrimaryColor ?? null,
brandAccentColor: parsedNs.brandAccentColor ?? null,
linkedUserId: parsedNs.linkedUserId ?? null,
bio: parsedNs.bio ?? null,
createdAt: new Date(parsedNs.createdAt),
Expand All @@ -86,6 +92,9 @@ export class PostgresNamespaceRepository implements NamespaceRepository {
if (updates.displayName !== undefined) set.displayName = updates.displayName;
if (updates.avatarUrl !== undefined) set.avatarUrl = updates.avatarUrl;
if (updates.icon !== undefined) set.icon = updates.icon;
if (updates.logo !== undefined) set.logo = updates.logo;
if (updates.brandPrimaryColor !== undefined) set.brandPrimaryColor = updates.brandPrimaryColor;
if (updates.brandAccentColor !== undefined) set.brandAccentColor = updates.brandAccentColor;
if (updates.linkedUserId !== undefined) set.linkedUserId = updates.linkedUserId;
if (updates.bio !== undefined) set.bio = updates.bio;
if (updates.createdAt !== undefined) set.createdAt = new Date(updates.createdAt);
Expand Down Expand Up @@ -259,6 +268,9 @@ function toNamespace(row: typeof workspaces.$inferSelect): Namespace {
createdAt: row.createdAt.toISOString(),
avatarUrl: row.avatarUrl ?? undefined,
icon: row.icon ?? undefined,
logo: row.logo ?? undefined,
brandPrimaryColor: row.brandPrimaryColor ?? undefined,
brandAccentColor: row.brandAccentColor ?? undefined,
linkedUserId: row.linkedUserId ?? undefined,
bio: row.bio ?? undefined,
});
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-infra/src/postgres/schema/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const workspaces = pgTable('workspaces', {
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
icon: text('icon'),
logo: text('logo'),
brandPrimaryColor: text('brand_primary_color'),
brandAccentColor: text('brand_accent_color'),
linkedUserId: text('linked_user_id'),
bio: text('bio'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
Expand Down
15 changes: 14 additions & 1 deletion packages/platform-ui/src/app/(app)/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,12 @@ function UserWorkspaces({ namespace }: { namespace: Namespace }) {
href={`/${ws.handle}`}
className="flex items-center gap-2.5 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
>
{(() => { const Icon = getWorkspaceIcon(ws.icon); return <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />; })()}
{ws.logo !== undefined && ws.logo !== '' ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={ws.logo} alt="" className="h-3.5 w-3.5 shrink-0 rounded object-cover" />
) : (
(() => { const Icon = getWorkspaceIcon(ws.icon); return <Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />; })()
)}
<span className="font-medium truncate">{ws.displayName}</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">@{ws.handle}</span>
</Link>
Expand Down Expand Up @@ -606,6 +611,14 @@ export default function ProfilePage() {
<div className="flex items-start gap-4">
{(() => {
if (namespace.type === 'organization') {
if (namespace.logo !== undefined && namespace.logo !== '') {
return (
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-primary/10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={namespace.logo} alt={namespace.displayName} className="h-full w-full object-cover" />
</div>
);
}
const Icon = getWorkspaceIcon(namespace.icon);
return (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-primary/10">
Expand Down
Loading