Skip to content

Commit 8254aec

Browse files
committed
Metadata attribution batch: fallback creator/editor resolution and admin repair utility
1 parent 66fe617 commit 8254aec

6 files changed

Lines changed: 192 additions & 9 deletions

File tree

docs/BACKLOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ State: stabilization pass (no net-new product features unless explicitly approve
4949
### Admin tooling
5050
- [ ] Build in-app admin utilities to reduce manual D1 SQL operations
5151
- Progress: added in-app deleted-user lock manager (list + restore) to remove direct SQL need for this flow.
52+
- Progress: added in-app metadata repair utility for created/last-edited backfill from ownership/change history.
5253
- [ ] Add user moderation actions and review queue ergonomics
5354
- [ ] Add simulation/site ownership repair tools in UI
55+
- Progress: ownership-related display gaps now repaired via metadata repair + fallback mapping; explicit owner reassignment UI still pending.
5456
- [ ] Add admin-safe bulk operations with confirmations and logs
5557

5658
### UI and wording consistency

functions/_lib/db.ts

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -818,12 +818,22 @@ const canEditByRole = (role: string | null, visibility: Visibility): boolean =>
818818
type LibraryRow = {
819819
payload_json: string;
820820
owner_user_id: string;
821+
owner_name: string | null;
822+
owner_avatar_url: string | null;
821823
visibility: Visibility;
822824
role: string | null;
823825
created_by_user_id: string | null;
824826
created_by_name: string | null;
827+
created_by_avatar_url: string | null;
828+
first_actor_user_id: string | null;
829+
first_actor_name: string | null;
830+
first_actor_avatar_url: string | null;
825831
last_edited_by_user_id: string | null;
826832
last_edited_by_name: string | null;
833+
last_edited_by_avatar_url: string | null;
834+
last_actor_user_id: string | null;
835+
last_actor_name: string | null;
836+
last_actor_avatar_url: string | null;
827837
created_at: string | null;
828838
last_edited_at: string | null;
829839
};
@@ -836,14 +846,25 @@ export const fetchLibraryForUser = async (
836846
const siteRows = await env.DB
837847
.prepare(
838848
`SELECT s.payload_json, s.owner_user_id, s.visibility, r.role,
849+
owner_u.username AS owner_name,
850+
owner_u.avatar_url AS owner_avatar_url,
839851
s.created_by_user_id,
840852
(SELECT u.username FROM users u WHERE u.id = s.created_by_user_id) AS created_by_name,
853+
(SELECT u.avatar_url FROM users u WHERE u.id = s.created_by_user_id) AS created_by_avatar_url,
854+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = s.id ORDER BY rc.changed_at ASC LIMIT 1) AS first_actor_user_id,
855+
(SELECT u.username FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = s.id ORDER BY rc.changed_at ASC LIMIT 1)) AS first_actor_name,
856+
(SELECT u.avatar_url FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = s.id ORDER BY rc.changed_at ASC LIMIT 1)) AS first_actor_avatar_url,
841857
s.last_edited_by_user_id,
842858
(SELECT u.username FROM users u WHERE u.id = s.last_edited_by_user_id) AS last_edited_by_name,
859+
(SELECT u.avatar_url FROM users u WHERE u.id = s.last_edited_by_user_id) AS last_edited_by_avatar_url,
860+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = s.id ORDER BY rc.changed_at DESC LIMIT 1) AS last_actor_user_id,
861+
(SELECT u.username FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = s.id ORDER BY rc.changed_at DESC LIMIT 1)) AS last_actor_name,
862+
(SELECT u.avatar_url FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = s.id ORDER BY rc.changed_at DESC LIMIT 1)) AS last_actor_avatar_url,
843863
s.created_at,
844864
s.last_edited_at
845865
FROM sites s
846866
LEFT JOIN site_roles r ON r.site_id = s.id AND r.user_id = ?
867+
LEFT JOIN users owner_u ON owner_u.id = s.owner_user_id
847868
WHERE s.owner_user_id = ? OR r.user_id IS NOT NULL OR s.visibility IN ('public_read', 'public_write')`,
848869
)
849870
.bind(userId, userId)
@@ -852,14 +873,25 @@ export const fetchLibraryForUser = async (
852873
const simulationRows = await env.DB
853874
.prepare(
854875
`SELECT s.payload_json, s.owner_user_id, s.visibility, r.role,
876+
owner_u.username AS owner_name,
877+
owner_u.avatar_url AS owner_avatar_url,
855878
s.created_by_user_id,
856879
(SELECT u.username FROM users u WHERE u.id = s.created_by_user_id) AS created_by_name,
880+
(SELECT u.avatar_url FROM users u WHERE u.id = s.created_by_user_id) AS created_by_avatar_url,
881+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = s.id ORDER BY rc.changed_at ASC LIMIT 1) AS first_actor_user_id,
882+
(SELECT u.username FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = s.id ORDER BY rc.changed_at ASC LIMIT 1)) AS first_actor_name,
883+
(SELECT u.avatar_url FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = s.id ORDER BY rc.changed_at ASC LIMIT 1)) AS first_actor_avatar_url,
857884
s.last_edited_by_user_id,
858885
(SELECT u.username FROM users u WHERE u.id = s.last_edited_by_user_id) AS last_edited_by_name,
886+
(SELECT u.avatar_url FROM users u WHERE u.id = s.last_edited_by_user_id) AS last_edited_by_avatar_url,
887+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = s.id ORDER BY rc.changed_at DESC LIMIT 1) AS last_actor_user_id,
888+
(SELECT u.username FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = s.id ORDER BY rc.changed_at DESC LIMIT 1)) AS last_actor_name,
889+
(SELECT u.avatar_url FROM users u WHERE u.id = (SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = s.id ORDER BY rc.changed_at DESC LIMIT 1)) AS last_actor_avatar_url,
859890
s.created_at,
860891
s.last_edited_at
861892
FROM simulations s
862893
LEFT JOIN simulation_roles r ON r.simulation_id = s.id AND r.user_id = ?
894+
LEFT JOIN users owner_u ON owner_u.id = s.owner_user_id
863895
WHERE s.owner_user_id = ? OR r.user_id IS NOT NULL OR s.visibility IN ('public_read', 'public_write')`,
864896
)
865897
.bind(userId, userId)
@@ -870,15 +902,27 @@ export const fetchLibraryForUser = async (
870902
.map((row) => {
871903
try {
872904
const parsed = JSON.parse(row.payload_json) as CloudResourceRecord;
905+
const createdByUserId = row.created_by_user_id ?? row.first_actor_user_id ?? row.owner_user_id;
906+
const createdByName = row.created_by_name ?? row.first_actor_name ?? row.owner_name ?? "Unknown";
907+
const createdByAvatarUrl =
908+
row.created_by_avatar_url ?? row.first_actor_avatar_url ?? row.owner_avatar_url ?? "";
909+
const lastEditedByUserId =
910+
row.last_edited_by_user_id ?? row.last_actor_user_id ?? createdByUserId ?? row.owner_user_id;
911+
const lastEditedByName =
912+
row.last_edited_by_name ?? row.last_actor_name ?? createdByName ?? row.owner_name ?? "Unknown";
913+
const lastEditedByAvatarUrl =
914+
row.last_edited_by_avatar_url ?? row.last_actor_avatar_url ?? createdByAvatarUrl ?? row.owner_avatar_url ?? "";
873915
return {
874916
...parsed,
875917
ownerUserId: row.owner_user_id,
876918
visibility: row.visibility,
877-
createdByUserId: row.created_by_user_id,
878-
createdByName: row.created_by_name,
919+
createdByUserId,
920+
createdByName,
921+
createdByAvatarUrl,
879922
createdAt: row.created_at,
880-
lastEditedByUserId: row.last_edited_by_user_id,
881-
lastEditedByName: row.last_edited_by_name,
923+
lastEditedByUserId,
924+
lastEditedByName,
925+
lastEditedByAvatarUrl,
882926
lastEditedAt: row.last_edited_at,
883927
effectiveRole:
884928
row.owner_user_id === userId
@@ -897,6 +941,71 @@ export const fetchLibraryForUser = async (
897941
};
898942
};
899943

944+
export const backfillResourceMetadata = async (
945+
env: Env,
946+
): Promise<{ sitesUpdated: number; simulationsUpdated: number }> => {
947+
await ensureSchema(env);
948+
949+
const siteResult = await env.DB
950+
.prepare(
951+
`UPDATE sites
952+
SET created_by_user_id = COALESCE(
953+
created_by_user_id,
954+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = sites.id ORDER BY rc.changed_at ASC LIMIT 1),
955+
owner_user_id
956+
),
957+
last_edited_by_user_id = COALESCE(
958+
last_edited_by_user_id,
959+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = sites.id ORDER BY rc.changed_at DESC LIMIT 1),
960+
created_by_user_id,
961+
owner_user_id
962+
),
963+
created_at = COALESCE(
964+
created_at,
965+
(SELECT rc.changed_at FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = sites.id ORDER BY rc.changed_at ASC LIMIT 1),
966+
updated_at
967+
),
968+
last_edited_at = COALESCE(
969+
last_edited_at,
970+
(SELECT rc.changed_at FROM resource_changes rc WHERE rc.resource_kind = 'site' AND rc.resource_id = sites.id ORDER BY rc.changed_at DESC LIMIT 1),
971+
updated_at
972+
)`,
973+
)
974+
.run();
975+
976+
const simulationResult = await env.DB
977+
.prepare(
978+
`UPDATE simulations
979+
SET created_by_user_id = COALESCE(
980+
created_by_user_id,
981+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = simulations.id ORDER BY rc.changed_at ASC LIMIT 1),
982+
owner_user_id
983+
),
984+
last_edited_by_user_id = COALESCE(
985+
last_edited_by_user_id,
986+
(SELECT rc.actor_user_id FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = simulations.id ORDER BY rc.changed_at DESC LIMIT 1),
987+
created_by_user_id,
988+
owner_user_id
989+
),
990+
created_at = COALESCE(
991+
created_at,
992+
(SELECT rc.changed_at FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = simulations.id ORDER BY rc.changed_at ASC LIMIT 1),
993+
updated_at
994+
),
995+
last_edited_at = COALESCE(
996+
last_edited_at,
997+
(SELECT rc.changed_at FROM resource_changes rc WHERE rc.resource_kind = 'simulation' AND rc.resource_id = simulations.id ORDER BY rc.changed_at DESC LIMIT 1),
998+
updated_at
999+
)`,
1000+
)
1001+
.run();
1002+
1003+
return {
1004+
sitesUpdated: Number((siteResult.meta as { changes?: number } | undefined)?.changes ?? 0),
1005+
simulationsUpdated: Number((simulationResult.meta as { changes?: number } | undefined)?.changes ?? 0),
1006+
};
1007+
};
1008+
9001009
export const fetchResourceChanges = async (
9011010
env: Env,
9021011
kind: "site" | "simulation",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { verifyAuth } from "../_lib/auth";
2+
import { assertUserAccess, backfillResourceMetadata, ensureUser, fetchUserProfile } from "../_lib/db";
3+
import { errorResponse, handleOptions, json, withCors } from "../_lib/http";
4+
import type { Env } from "../_lib/types";
5+
6+
export const onRequestOptions: PagesFunction<Env> = async ({ request }) => handleOptions(request);
7+
8+
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
9+
try {
10+
const auth = await verifyAuth(request, env);
11+
if (!auth) return withCors(request, json({ error: "Unauthorized" }, { status: 401 }));
12+
await ensureUser(env, auth.userId, auth.tokenPayload);
13+
await assertUserAccess(env, auth.userId);
14+
const me = await fetchUserProfile(env, auth.userId);
15+
if (!me) return withCors(request, json({ error: "Unauthorized" }, { status: 401 }));
16+
if (!me.isAdmin) return withCors(request, json({ error: "Forbidden" }, { status: 403 }));
17+
18+
const result = await backfillResourceMetadata(env);
19+
return withCors(request, json({ ok: true, ...result }));
20+
} catch (error) {
21+
return errorResponse(request, error, 500);
22+
}
23+
};

src/components/Sidebar.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,10 @@ export function Sidebar() {
390390
label: string;
391391
createdByUserId: string | null;
392392
createdByName: string;
393+
createdByAvatarUrl: string;
393394
lastEditedByUserId: string | null;
394395
lastEditedByName: string;
396+
lastEditedByAvatarUrl: string;
395397
} | null>(null);
396398
const [storageOriginWarning, setStorageOriginWarning] = useState("");
397399
const [storageSnapshotInfo, setStorageSnapshotInfo] = useState(() => ({
@@ -982,25 +984,31 @@ export function Sidebar() {
982984
label,
983985
createdByUserId,
984986
createdByName,
987+
createdByAvatarUrl,
985988
lastEditedByUserId,
986989
lastEditedByName,
990+
lastEditedByAvatarUrl,
987991
}: {
988992
kind: "site" | "simulation";
989993
resourceId: string;
990994
label: string;
991995
createdByUserId: string | null;
992996
createdByName: string;
997+
createdByAvatarUrl: string;
993998
lastEditedByUserId: string | null;
994999
lastEditedByName: string;
1000+
lastEditedByAvatarUrl: string;
9951001
}) => {
9961002
setResourceDetailsPopup({
9971003
kind,
9981004
resourceId,
9991005
label,
10001006
createdByUserId,
10011007
createdByName,
1008+
createdByAvatarUrl,
10021009
lastEditedByUserId,
10031010
lastEditedByName,
1011+
lastEditedByAvatarUrl,
10041012
});
10051013
};
10061014

@@ -1826,14 +1834,15 @@ export function Sidebar() {
18261834
onClick={() => void openUserProfilePopup(resourceDetailsPopup.createdByUserId)}
18271835
type="button"
18281836
>
1829-
Created by <UserBadge name={resourceDetailsPopup.createdByName} />
1837+
Created by <UserBadge avatarUrl={resourceDetailsPopup.createdByAvatarUrl} name={resourceDetailsPopup.createdByName} />
18301838
</button>
18311839
<button
18321840
className="inline-action"
18331841
onClick={() => void openUserProfilePopup(resourceDetailsPopup.lastEditedByUserId)}
18341842
type="button"
18351843
>
1836-
Last edited by <UserBadge name={resourceDetailsPopup.lastEditedByName} />
1844+
Last edited by{" "}
1845+
<UserBadge avatarUrl={resourceDetailsPopup.lastEditedByAvatarUrl} name={resourceDetailsPopup.lastEditedByName} />
18371846
</button>
18381847
<button
18391848
className="inline-action"
@@ -1942,10 +1951,14 @@ export function Sidebar() {
19421951
label: preset.name,
19431952
createdByUserId: (preset as unknown as { createdByUserId?: string }).createdByUserId ?? null,
19441953
createdByName: (preset as unknown as { createdByName?: string }).createdByName ?? "Unknown",
1954+
createdByAvatarUrl:
1955+
(preset as unknown as { createdByAvatarUrl?: string }).createdByAvatarUrl ?? "",
19451956
lastEditedByUserId:
19461957
(preset as unknown as { lastEditedByUserId?: string }).lastEditedByUserId ?? null,
19471958
lastEditedByName:
19481959
(preset as unknown as { lastEditedByName?: string }).lastEditedByName ?? "Unknown",
1960+
lastEditedByAvatarUrl:
1961+
(preset as unknown as { lastEditedByAvatarUrl?: string }).lastEditedByAvatarUrl ?? "",
19491962
})
19501963
}
19511964
type="button"
@@ -2251,10 +2264,14 @@ export function Sidebar() {
22512264
label: entry.name,
22522265
createdByUserId: (entry as unknown as { createdByUserId?: string }).createdByUserId ?? null,
22532266
createdByName: (entry as unknown as { createdByName?: string }).createdByName ?? "Unknown",
2267+
createdByAvatarUrl:
2268+
(entry as unknown as { createdByAvatarUrl?: string }).createdByAvatarUrl ?? "",
22542269
lastEditedByUserId:
22552270
(entry as unknown as { lastEditedByUserId?: string }).lastEditedByUserId ?? null,
22562271
lastEditedByName:
22572272
(entry as unknown as { lastEditedByName?: string }).lastEditedByName ?? "Unknown",
2273+
lastEditedByAvatarUrl:
2274+
(entry as unknown as { lastEditedByAvatarUrl?: string }).lastEditedByAvatarUrl ?? "",
22582275
})
22592276
}
22602277
type="button"

src/components/UserAdminPanel.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
fetchMe,
77
fetchSchemaDiagnostics,
88
fetchUsers,
9+
runMetadataRepair,
910
restoreDeletedCloudUser,
1011
updateMyProfile,
1112
updateUserAdmin,
@@ -124,6 +125,23 @@ export function UserAdminPanel() {
124125
});
125126
};
126127

128+
const repairMetadata = async () => {
129+
setBusy(true);
130+
setStatus("");
131+
try {
132+
const result = await runMetadataRepair();
133+
await refreshAdminData();
134+
setStatus(
135+
`Metadata repair completed. Sites updated: ${result.sitesUpdated}. Simulations updated: ${result.simulationsUpdated}.`,
136+
);
137+
} catch (error) {
138+
const message = error instanceof Error ? error.message : String(error);
139+
setStatus(`Metadata repair failed: ${message}`);
140+
} finally {
141+
setBusy(false);
142+
}
143+
};
144+
127145
const loadNotifications = async () => {
128146
if (!canAdmin) return;
129147
setNotificationBusy(true);
@@ -464,9 +482,14 @@ export function UserAdminPanel() {
464482
<div className="user-manager-list">
465483
<div className="section-heading">
466484
<p className="field-help">System diagnostics</p>
467-
<button className="inline-action" disabled={busy} onClick={() => void load()} type="button">
468-
Refresh
469-
</button>
485+
<div className="chip-group">
486+
<button className="inline-action" disabled={busy} onClick={() => void load()} type="button">
487+
Refresh
488+
</button>
489+
<button className="inline-action" disabled={busy} onClick={() => void repairMetadata()} type="button">
490+
Repair Metadata
491+
</button>
492+
</div>
470493
</div>
471494
{authWarnings.length ? (
472495
<div className="notification-banner" role="status">

src/lib/cloudUser.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ export type SchemaDiagnostics = {
6666
};
6767
};
6868

69+
export type MetadataRepairResult = {
70+
ok: boolean;
71+
sitesUpdated: number;
72+
simulationsUpdated: number;
73+
};
74+
6975
const apiCall = async <T>(path: string, init?: RequestInit): Promise<T> => {
7076
const response = await fetch(path, {
7177
...init,
@@ -184,3 +190,6 @@ export const fetchAuthDiagnostics = async (): Promise<AuthDiagnostics> =>
184190

185191
export const fetchSchemaDiagnostics = async (): Promise<SchemaDiagnostics> =>
186192
apiCall<SchemaDiagnostics>("/api/schema-diagnostics", { method: "GET" });
193+
194+
export const runMetadataRepair = async (): Promise<MetadataRepairResult> =>
195+
apiCall<MetadataRepairResult>("/api/admin-repair-metadata", { method: "POST" });

0 commit comments

Comments
 (0)