12 Commits
Author SHA1 Message Date
jjbrunton f4687f95fe Merge pull request #29 from jjbrunton/staging
fix(admin): prevent blank dashboard after login
2026-09-05 17:12:45 +01:00
jjbrunton 53a48ab6d5 fix(admin): use feature interest composite key 2026-09-05 17:02:07 +01:00
jjbrunton a6233d0175 fix(admin): render standalone feature interests 2026-09-05 16:57:57 +01:00
jjbrunton c776859183 Merge pull request #28 from jjbrunton/staging
Promote staging to main: Apple review onboarding fixes and v1.0.8 build 49
2026-09-01 16:16:37 +01:00
jjbrunton cc12f5f0ff Merge remote-tracking branch 'origin/main' into sync/staging-backmerge 2026-09-01 16:10:11 +01:00
jjbrunton fe1248c59d Merge pull request #25 from jjbrunton/staging
Staging
2026-08-31 20:56:43 +01:00
jjbrunton a06f1b69e8 Merge pull request #27 from jjbrunton/hotfix/admin-cors-origin
fix(api): allow the admin console origin through CORS
2026-08-31 18:22:23 +01:00
jjbruntonandClaude Opus 5 3a019e62d3 fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:26 +01:00
jjbrunton 84712a950e Merge pull request #26 from jjbrunton/hotfix/admin-docker-context
fix(admin): restore admin docker build context, allow hotfix branches into main
2026-08-31 18:08:31 +01:00
jjbruntonandClaude Opus 5 d14869d745 ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 f1573f4906 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbrunton 83df3b45f8 Merge pull request #23 from jjbrunton/staging
Release v1.0.8: Expo SDK 56 and EAS Observe
2026-08-31 15:47:29 +01:00
3 changed files with 65 additions and 30 deletions

No files matched your search

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { featureInterestKey, formatFeatureName, toRecentFeatureInterest } from './featureInterests';
describe('toRecentFeatureInterest', () => {
it('preserves the standalone API feature field for dashboard rendering', () => {
const interest = toRecentFeatureInterest({
user_id: 'user-1',
feature: 'better_chat',
created_at: '2026-09-05T12:00:00.000Z',
});
expect(interest).toEqual({
user_id: 'user-1',
feature: 'better_chat',
created_at: '2026-09-05T12:00:00.000Z',
});
expect(formatFeatureName(interest.feature)).toBe('Better Chat');
expect(featureInterestKey(interest)).toBe('user-1:better_chat');
});
});
+34
View File
@@ -0,0 +1,34 @@
export interface FeatureInterestRow {
user_id: string;
feature: string;
created_at: string;
}
export interface RecentFeatureInterest extends FeatureInterestRow {
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
}
export function toRecentFeatureInterest(row: FeatureInterestRow): RecentFeatureInterest {
return {
user_id: row.user_id,
feature: row.feature,
created_at: row.created_at,
};
}
export function featureInterestKey(row: Pick<FeatureInterestRow, 'user_id' | 'feature'>): string {
return `${row.user_id}:${row.feature}`;
}
export function formatFeatureName(feature: string): string {
return feature
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
+11 -30
View File
@@ -10,36 +10,22 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recha
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns'; import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import {
formatFeatureName,
featureInterestKey,
toRecentFeatureInterest,
type FeatureInterestRow,
type RecentFeatureInterest,
} from '@/lib/featureInterests';
type FeatureInterestChartPoint = { date: string; count: number }; type FeatureInterestChartPoint = { date: string; count: number };
type RecentFeatureInterest = {
id: string;
user_id: string;
feature_name: string;
created_at: string;
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
};
type FeatureInterestCountsRow = { type FeatureInterestCountsRow = {
feature_name: string; feature_name: string;
opt_in_count: number; opt_in_count: number;
opt_in_count_last_7_days: number; opt_in_count_last_7_days: number;
}; };
function formatFeatureName(featureName: string) {
return featureName
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function DashboardPage() { export function DashboardPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS); const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
@@ -95,7 +81,7 @@ export function DashboardPage() {
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString()) ? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
canViewUsers canViewUsers
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10) ? adminData.from<FeatureInterestRow>('feature_interests').select('user_id, feature, created_at').order('created_at', { ascending: false }).limit(10)
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
]); ]);
@@ -158,12 +144,7 @@ export function DashboardPage() {
})) }))
); );
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({ let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(toRecentFeatureInterest);
id: item.id,
user_id: item.user_id,
feature_name: item.feature_name,
created_at: item.created_at,
}));
if (canViewUsers && recentActivity.length > 0) { if (canViewUsers && recentActivity.length > 0) {
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id))); const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
@@ -579,14 +560,14 @@ export function DashboardPage() {
if (item.profile?.name && item.profile.email) { if (item.profile?.name && item.profile.email) {
subtitleParts.push(item.profile.email); subtitleParts.push(item.profile.email);
} }
subtitleParts.push(formatFeatureName(item.feature_name)); subtitleParts.push(formatFeatureName(item.feature));
const fallbackChar = (item.profile?.name || item.profile?.email || 'U') const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
.charAt(0) .charAt(0)
.toUpperCase(); .toUpperCase();
return ( return (
<div key={item.id} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3"> <div key={featureInterestKey(item)} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={item.profile?.avatar_url || undefined} /> <AvatarImage src={item.profile?.avatar_url || undefined} />