Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Every non-trivial PR adds a bullet under `## [Unreleased]`. Trivial edits (typos
## [Unreleased]

### Fixed
- Workspace-invite and resend-invite emails no longer link to `localhost` when the deployment env is misconfigured: the invite/login/workspace URL now resolves at send time from a new `platform.baseUrl` platform setting first, falling back to `NEXT_PUBLIC_PLATFORM_URL` then localhost as before. Operators can point invite links at the real host with a single `mediforce config set platform.baseUrl https://<host>` (no `.env`/compose-passthrough edit, no SSH). Resolution is deployment-global (every workspace shares the host; the handle is already the path segment), read via `scope.system.platformSettings` in the invite/resend handlers and applied by the email adapter over its construction-time fallback.
- `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).
- Cancelling a run now cancels its still-actionable (`pending`/`claimed`) human tasks too, so dead runs no longer leave zombie "Action needed" items lingering in the task queue; the cancel audit event records how many tasks were cancelled [#639](https://github.com/Appsilon/mediforce/pull/639).
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-api/src/contract/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { z } from 'zod';

/**
* Platform-settings key holding the deployment's public base URL (e.g.
* `https://phuse.mediforce.ai`). Read at invite time to build workspace/login
* links; set via `mediforce config set platform.baseUrl <url>`. Falls back to
* `NEXT_PUBLIC_PLATFORM_URL` then localhost when unset.
*/
export const PLATFORM_BASE_URL_SETTING_KEY = 'platform.baseUrl';
Comment on lines +3 to +9

export const GetConfigInputSchema = z.object({ key: z.string().min(1) });
export type GetConfigInput = z.infer<typeof GetConfigInputSchema>;
export const GetConfigOutputSchema = z.object({ key: z.string(), value: z.string().nullable() });
Expand Down
16 changes: 16 additions & 0 deletions packages/platform-api/src/handlers/_helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { NotFoundError } from '../errors';
import { PLATFORM_BASE_URL_SETTING_KEY } from '../contract/config';
import type { CallerScope } from '../repositories/index';

/**
* Resolve the deployment's configured public base URL from platform settings,
* or `undefined` when unset. Notification adapters use this to override their
* construction-time `NEXT_PUBLIC_PLATFORM_URL`/localhost fallback so invite
* links point at the real host. A trailing slash is stripped so callers can
* safely append `/login` etc.
*/
export async function resolveConfiguredBaseUrl(
scope: CallerScope,
): Promise<string | undefined> {
const value = await scope.system.platformSettings.get(PLATFORM_BASE_URL_SETTING_KEY);
const trimmed = value?.trim().replace(/\/+$/, '') ?? '';
return trimmed === '' ? undefined : trimmed;
}

export async function loadOr404<T>(
lookup: Promise<T | null>,
notFoundMessage: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InMemoryAuditRepository } from '@mediforce/platform-core/testing';
import {
InMemoryAuditRepository,
InMemoryPlatformSettingsRepository,
} from '@mediforce/platform-core/testing';
import { InMemoryNamespaceRepo, createTestScope, userCaller } from '../../../testing/index';
import { inviteUser } from '../invite-user';
import { ForbiddenError, PreconditionFailedError } from '../../../errors';
Expand Down Expand Up @@ -306,6 +309,99 @@ describe('inviteUser handler', () => {
expect(namespaceRepo.members.get('alpha')).toHaveLength(1);
});

it('passes the configured platform.baseUrl through to the invite email', async () => {
const platformSettingsRepo = new InMemoryPlatformSettingsRepository();
await platformSettingsRepo.set('platform.baseUrl', 'https://phuse.mediforce.ai');
const inviteService = inviteServiceReturning({
uid: 'uid-new',
temporaryPassword: 'Mf-XYZ',
isExisting: false,
});
const notifier = recordingNotifier();
const scope = createTestScope({
namespaceRepo,
auditRepo,
inviteService,
inviteNotificationService: notifier,
platformSettingsRepo,
});

await inviteUser(baseInput, scope);

expect(notifier.sendInviteEmailCalls).toEqual([
{
toEmail: 'newbie@example.test',
temporaryPassword: 'Mf-XYZ',
baseUrl: 'https://phuse.mediforce.ai',
},
]);
});

it('passes the configured platform.baseUrl through to the workspace-notification email (trailing slash trimmed)', async () => {
const platformSettingsRepo = new InMemoryPlatformSettingsRepository();
await platformSettingsRepo.set('platform.baseUrl', 'https://phuse.mediforce.ai/');
const inviteService = inviteServiceReturning({
uid: 'uid-existing',
temporaryPassword: '',
isExisting: true,
});
const notifier = recordingNotifier();
const scope = createTestScope({
namespaceRepo,
auditRepo,
inviteService,
inviteNotificationService: notifier,
platformSettingsRepo,
});

await inviteUser({ ...baseInput, inviterName: 'Marek' }, scope);

expect(notifier.sendWorkspaceCalls[0]).toMatchObject({
baseUrl: 'https://phuse.mediforce.ai',
});
});

it('omits baseUrl when platform.baseUrl is unset', async () => {
const inviteService = inviteServiceReturning({
uid: 'uid-new',
temporaryPassword: 'Mf-XYZ',
isExisting: false,
});
const notifier = recordingNotifier();
const scope = createTestScope({
namespaceRepo,
auditRepo,
inviteService,
inviteNotificationService: notifier,
});

await inviteUser(baseInput, scope);

expect(notifier.sendInviteEmailCalls[0].baseUrl).toBeUndefined();
});

it('omits baseUrl when platform.baseUrl is cleared to whitespace (falls back, never an empty URL)', async () => {
const platformSettingsRepo = new InMemoryPlatformSettingsRepository();
await platformSettingsRepo.set('platform.baseUrl', ' ');
const inviteService = inviteServiceReturning({
uid: 'uid-new',
temporaryPassword: 'Mf-XYZ',
isExisting: false,
});
const notifier = recordingNotifier();
const scope = createTestScope({
namespaceRepo,
auditRepo,
inviteService,
inviteNotificationService: notifier,
platformSettingsRepo,
});

await inviteUser(baseInput, scope);

expect(notifier.sendInviteEmailCalls[0].baseUrl).toBeUndefined();
});

it('writes an invitation.created audit event attributed to the caller', async () => {
const inviteService = inviteServiceReturning({
uid: 'uid-new',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InMemoryAuditRepository } from '@mediforce/platform-core/testing';
import {
InMemoryAuditRepository,
InMemoryPlatformSettingsRepository,
} from '@mediforce/platform-core/testing';
import { resendInvite } from '../resend-invite';
import {
ForbiddenError,
Expand Down Expand Up @@ -96,6 +99,33 @@ describe('resendInvite handler', () => {
]);
});

it('passes the configured platform.baseUrl through to the resent invite email', async () => {
const platformSettingsRepo = new InMemoryPlatformSettingsRepository();
await platformSettingsRepo.set('platform.baseUrl', 'https://phuse.mediforce.ai');
const inviteService = inviteServiceStub({
email: 'pending@example.test',
pending: true,
resetPassword: 'Mf-RESET',
});
const notifier = recordingNotifier();
const scope = createTestScope({
auditRepo,
inviteService,
inviteNotificationService: notifier,
platformSettingsRepo,
});

await resendInvite(baseInput, scope);

expect(notifier.sendInviteEmailCalls).toEqual([
{
toEmail: 'pending@example.test',
temporaryPassword: 'Mf-RESET',
baseUrl: 'https://phuse.mediforce.ai',
},
]);
});

it('proceeds for an admin caller of the namespace', async () => {
const inviteService = inviteServiceStub({
email: 'pending@example.test',
Expand Down
10 changes: 8 additions & 2 deletions packages/platform-api/src/handlers/users/invite-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { assertCallerIsNamespaceAdmin } from '../../auth';
import { PreconditionFailedError } from '../../errors';
import type { CallerScope } from '../../repositories/index';
import type { InviteUserInput, InviteUserOutput } from '../../contract/users';
import { actorFromCaller } from '../_helpers';
import { actorFromCaller, resolveConfiguredBaseUrl } from '../_helpers';

/**
* Invite a user to a workspace.
Expand Down Expand Up @@ -61,6 +61,7 @@ export async function inviteUser(
const notify = scope.system.inviteNotificationService;
if (notify !== null) {
try {
const baseUrl = await resolveConfiguredBaseUrl(scope);
if (isExisting) {
const namespace = await scope.workspaces.getNamespace(input.namespaceHandle);
const workspaceName = namespace?.displayName ?? input.namespaceHandle;
Expand All @@ -73,9 +74,14 @@ export async function inviteUser(
inviterName,
workspaceName,
workspaceHandle: input.namespaceHandle,
...(baseUrl !== undefined ? { baseUrl } : {}),
});
} else {
await notify.sendInviteEmail({ toEmail: email, temporaryPassword });
await notify.sendInviteEmail({
toEmail: email,
temporaryPassword,
...(baseUrl !== undefined ? { baseUrl } : {}),
});
}
emailSent = true;
} catch (emailErr) {
Expand Down
9 changes: 7 additions & 2 deletions packages/platform-api/src/handlers/users/resend-invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { assertCallerIsNamespaceAdmin } from '../../auth';
import { HandlerError, PreconditionFailedError } from '../../errors';
import type { CallerScope } from '../../repositories/index';
import type { ResendInviteInput, ResendInviteOutput } from '../../contract/users';
import { actorFromCaller } from '../_helpers';
import { actorFromCaller, resolveConfiguredBaseUrl } from '../_helpers';

/**
* Re-issue an invite for a pending workspace member.
Expand Down Expand Up @@ -53,7 +53,12 @@ export async function resendInvite(
const notify = scope.system.inviteNotificationService;
if (notify !== null) {
try {
await notify.sendInviteEmail({ toEmail: email, temporaryPassword });
const baseUrl = await resolveConfiguredBaseUrl(scope);
await notify.sendInviteEmail({
toEmail: email,
temporaryPassword,
...(baseUrl !== undefined ? { baseUrl } : {}),
});
emailSent = true;
} catch (emailErr) {
console.error('[resend-invite] Failed to send email:', emailErr);
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-api/src/services/invite-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,21 @@ export interface InviteService {
export interface SendInviteEmailInput {
readonly toEmail: string;
readonly temporaryPassword: string;
/**
* Overrides the adapter's construction-time app URL when the deployment has
* configured a `platform.baseUrl` setting. Absent → the adapter falls back to
* `NEXT_PUBLIC_PLATFORM_URL` → localhost.
*/
readonly baseUrl?: string;
}

export interface SendWorkspaceNotificationEmailInput {
readonly toEmail: string;
readonly inviterName: string;
readonly workspaceName: string;
readonly workspaceHandle: string;
/** See {@link SendInviteEmailInput.baseUrl}. */
readonly baseUrl?: string;
}

/**
Expand Down
13 changes: 10 additions & 3 deletions packages/platform-api/src/services/platform-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,20 +223,27 @@ class EmailInviteNotificationService implements InviteNotificationService {
) {}

async sendInviteEmail(input: SendInviteEmailInput): Promise<void> {
const appUrl = input.baseUrl ?? this.appUrl;
await sendInviteEmail(
{ ...input, appUrl: this.appUrl, senderName: this.senderName },
{
toEmail: input.toEmail,
temporaryPassword: input.temporaryPassword,
appUrl,
senderName: this.senderName,
},
this.sendEmail,
);
}

async sendWorkspaceNotificationEmail(input: SendWorkspaceNotificationEmailInput): Promise<void> {
const appUrl = input.baseUrl ?? this.appUrl;
await sendWorkspaceNotificationEmail(
{
toEmail: input.toEmail,
inviterName: input.inviterName,
workspaceName: input.workspaceName,
workspaceUrl: `${this.appUrl}/${input.workspaceHandle}`,
appUrl: this.appUrl,
workspaceUrl: `${appUrl}/${input.workspaceHandle}`,
appUrl,
senderName: this.senderName,
},
this.sendEmail,
Expand Down