feat(ai): finalize AI magic edits epic rollout and operations
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run

This commit is contained in:
Codex Agent
2026-02-06 22:41:51 +01:00
parent 36bed12ff9
commit 1d2242fb4d
33 changed files with 2621 additions and 18 deletions

View File

@@ -337,6 +337,27 @@
"title": "Gäste-Nutzung Warnung",
"body": "{{event}} liegt bei {{used}} / {{limit}} Gästen."
},
"aiEditSucceeded": {
"title": "AI-Edit abgeschlossen",
"body": "{{event}} hat ein fertiges AI-Edit."
},
"aiEditFailed": {
"title": "AI-Edit fehlgeschlagen",
"body": "{{event}} konnte ein AI-Edit nicht abschließen. Grund: {{reason}}.",
"reasonUnknown": "unbekannt"
},
"aiEditBlocked": {
"title": "AI-Edit blockiert",
"body": "Ein AI-Edit für {{event}} wurde durch Sicherheitsprüfungen blockiert."
},
"aiBudgetSoftCap": {
"title": "AI-Budget Warnung",
"body": "{{event}} hat {{spend}} USD von {{cap}} USD AI-Budget erreicht."
},
"aiBudgetHardCap": {
"title": "AI-Budget ausgeschöpft",
"body": "{{event}} hat das harte AI-Budget-Limit von {{cap}} USD erreicht."
},
"generic": {
"body": "Benachrichtigung über {{channel}}."
},
@@ -354,6 +375,7 @@
"gallery": "Galerie",
"events": "Events",
"package": "Paket",
"ai": "AI",
"general": "Allgemein"
},
"markAllRead": "Alle als gelesen markieren",

View File

@@ -333,6 +333,27 @@
"title": "Guest usage warning",
"body": "{{event}} is at {{used}} / {{limit}} guests."
},
"aiEditSucceeded": {
"title": "AI edit completed",
"body": "{{event}} has a completed AI edit ready."
},
"aiEditFailed": {
"title": "AI edit failed",
"body": "{{event}} could not finish an AI edit. Reason: {{reason}}.",
"reasonUnknown": "unknown"
},
"aiEditBlocked": {
"title": "AI edit blocked",
"body": "{{event}} AI edit was blocked by safety checks."
},
"aiBudgetSoftCap": {
"title": "AI budget warning",
"body": "{{event}} reached {{spend}} USD of {{cap}} USD AI budget."
},
"aiBudgetHardCap": {
"title": "AI budget exhausted",
"body": "{{event}} reached the hard AI budget cap of {{cap}} USD."
},
"generic": {
"body": "Notification sent via {{channel}}."
},
@@ -350,6 +371,7 @@
"gallery": "Gallery",
"events": "Events",
"package": "Package",
"ai": "AI",
"general": "General"
},
"markAllRead": "Mark all read",

View File

@@ -144,6 +144,10 @@ function formatLog(
const used = typeof ctx.used === 'number' ? ctx.used : null;
const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null;
const days = typeof ctx.day === 'number' ? ctx.day : null;
const spendUsd = typeof ctx.spend_usd === 'number' ? ctx.spend_usd : null;
const softCapUsd = typeof ctx.soft_cap_usd === 'number' ? ctx.soft_cap_usd : null;
const hardCapUsd = typeof ctx.hard_cap_usd === 'number' ? ctx.hard_cap_usd : null;
const failureCode = typeof ctx.failure_code === 'string' ? ctx.failure_code : null;
const ctxEventId = ctx.event_id ?? ctx.eventId;
const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null);
const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event');
@@ -165,6 +169,12 @@ function formatLog(
case 'package_expiring':
case 'package_expired':
return 'package';
case 'ai_edit_succeeded':
case 'ai_edit_failed':
case 'ai_edit_blocked':
case 'ai_budget_soft_cap':
case 'ai_budget_hard_cap':
return 'ai';
default:
return 'general';
}
@@ -276,6 +286,80 @@ function formatLog(
is_read: isRead,
scope,
};
case 'ai_edit_succeeded':
return {
id: String(log.id),
title: t('notificationLogs.aiEditSucceeded.title', 'AI edit completed'),
body: t('notificationLogs.aiEditSucceeded.body', '{{event}} has a completed AI edit ready.', {
event: name,
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_edit_failed':
return {
id: String(log.id),
title: t('notificationLogs.aiEditFailed.title', 'AI edit failed'),
body: t('notificationLogs.aiEditFailed.body', '{{event}} could not finish an AI edit. Reason: {{reason}}.', {
event: name,
reason: failureCode ?? t('notificationLogs.aiEditFailed.reasonUnknown', 'unknown'),
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_edit_blocked':
return {
id: String(log.id),
title: t('notificationLogs.aiEditBlocked.title', 'AI edit blocked'),
body: t('notificationLogs.aiEditBlocked.body', '{{event}} AI edit was blocked by safety checks.', {
event: name,
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_budget_soft_cap':
return {
id: String(log.id),
title: t('notificationLogs.aiBudgetSoftCap.title', 'AI budget warning'),
body: t('notificationLogs.aiBudgetSoftCap.body', '{{event}} reached {{spend}} USD of {{cap}} USD AI budget.', {
event: name,
spend: spendUsd ?? '—',
cap: softCapUsd ?? '—',
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_budget_hard_cap':
return {
id: String(log.id),
title: t('notificationLogs.aiBudgetHardCap.title', 'AI budget exhausted'),
body: t('notificationLogs.aiBudgetHardCap.body', '{{event}} reached the hard AI budget cap of {{cap}} USD.', {
event: name,
cap: hardCapUsd ?? '—',
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
default:
return {
id: String(log.id),
@@ -379,6 +463,16 @@ export default function MobileNotificationsPage() {
};
}, [reload]);
React.useEffect(() => {
const interval = window.setInterval(() => {
void reload();
}, 90_000);
return () => {
window.clearInterval(interval);
};
}, [reload]);
React.useEffect(() => {
(async () => {
try {
@@ -588,6 +682,7 @@ export default function MobileNotificationsPage() {
{ key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
{ key: 'ai', label: t('notificationLogs.scope.ai', 'AI') },
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
const isActive = (scopeParam ?? 'all') === filter.key;

View File

@@ -10,6 +10,8 @@ export function useNotificationsBadge() {
const { data: count = 0 } = useQuery<number>({
queryKey: ['mobile', 'notifications', 'badge', 'tenant'],
staleTime: 60_000,
refetchInterval: 90_000,
refetchIntervalInBackground: true,
queryFn: async () => {
const logs = await listNotificationLogs({ perPage: 1 });
const meta: any = logs.meta ?? {};

View File

@@ -30,4 +30,14 @@ describe('groupNotificationsByScope', () => {
const grouped = groupNotificationsByScope(items);
expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']);
});
it('places ai scope before general', () => {
const items: Item[] = [
{ id: '1', scope: 'general', is_read: true },
{ id: '2', scope: 'ai', is_read: true },
];
const grouped = groupNotificationsByScope(items);
expect(grouped.map((group) => group.scope)).toEqual(['ai', 'general']);
});
});

View File

@@ -1,4 +1,4 @@
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'ai' | 'general';
export type ScopedNotification = {
scope: NotificationScope;
@@ -17,6 +17,7 @@ const SCOPE_ORDER: NotificationScope[] = [
'gallery',
'events',
'package',
'ai',
'general',
];