diff --git a/drizzle/0007_label_pds_records.sql b/drizzle/0007_label_pds_records.sql new file mode 100644 index 0000000..63bdec7 --- /dev/null +++ b/drizzle/0007_label_pds_records.sql @@ -0,0 +1,15 @@ +CREATE TABLE `label_pds_records` ( + `entity_id` text NOT NULL, + `entity_type` text NOT NULL, + `actor_did` text NOT NULL, + `rkey` text NOT NULL, + PRIMARY KEY(`entity_id`, `entity_type`, `actor_did`) +); +--> statement-breakpoint +ALTER TABLE `feature_requests` RENAME COLUMN `label_tid` TO `label_updated_at`; +--> statement-breakpoint +ALTER TABLE `kanban_tasks` RENAME COLUMN `label_tid` TO `label_updated_at`; +--> statement-breakpoint +UPDATE `feature_requests` SET `label_updated_at` = NULL WHERE `label_updated_at` IS NOT NULL; +--> statement-breakpoint +UPDATE `kanban_tasks` SET `label_updated_at` = NULL WHERE `label_updated_at` IS NOT NULL; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json index 0a24e7b..461f965 100644 --- a/drizzle/meta/0006_snapshot.json +++ b/drizzle/meta/0006_snapshot.json @@ -134,12 +134,8 @@ "name": "sphere_entry_counter_sphere_id_spheres_id_fk", "tableFrom": "sphere_entry_counter", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -176,10 +172,7 @@ "indexes": { "idx_entity_labels_entity": { "name": "idx_entity_labels_entity", - "columns": [ - "entity_id", - "entity_type" - ], + "columns": ["entity_id", "entity_type"], "isUnique": false } }, @@ -188,22 +181,15 @@ "name": "entity_labels_label_id_sphere_labels_id_fk", "tableFrom": "entity_labels", "tableTo": "sphere_labels", - "columnsFrom": [ - "label_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["label_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": { "entity_labels_label_id_entity_id_pk": { - "columns": [ - "label_id", - "entity_id" - ], + "columns": ["label_id", "entity_id"], "name": "entity_labels_label_id_entity_id_pk" } }, @@ -268,18 +254,12 @@ "indexes": { "idx_sphere_labels_sphere_name": { "name": "idx_sphere_labels_sphere_name", - "columns": [ - "sphere_id", - "name" - ], + "columns": ["sphere_id", "name"], "isUnique": true }, "idx_sphere_labels_sphere_position": { "name": "idx_sphere_labels_sphere_position", - "columns": [ - "sphere_id", - "position" - ], + "columns": ["sphere_id", "position"], "isUnique": false } }, @@ -288,12 +268,8 @@ "name": "sphere_labels_sphere_id_spheres_id_fk", "tableFrom": "sphere_labels", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -368,9 +344,7 @@ "indexes": { "idx_sphere_members_did": { "name": "idx_sphere_members_did", - "columns": [ - "did" - ], + "columns": ["did"], "isUnique": false } }, @@ -379,22 +353,15 @@ "name": "sphere_members_sphere_id_spheres_id_fk", "tableFrom": "sphere_members", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": { "sphere_members_sphere_id_did_pk": { - "columns": [ - "sphere_id", - "did" - ], + "columns": ["sphere_id", "did"], "name": "sphere_members_sphere_id_did_pk" } }, @@ -433,22 +400,15 @@ "name": "sphere_modules_sphere_id_spheres_id_fk", "tableFrom": "sphere_modules", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": { "sphere_modules_sphere_id_module_name_pk": { - "columns": [ - "sphere_id", - "module_name" - ], + "columns": ["sphere_id", "module_name"], "name": "sphere_modules_sphere_id_module_name_pk" } }, @@ -494,22 +454,15 @@ "name": "sphere_permissions_sphere_id_spheres_id_fk", "tableFrom": "sphere_permissions", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": { "sphere_permissions_sphere_id_action_key_pk": { - "columns": [ - "sphere_id", - "action_key" - ], + "columns": ["sphere_id", "action_key"], "name": "sphere_permissions_sphere_id_action_key_pk" } }, @@ -589,9 +542,7 @@ "indexes": { "spheres_handle_unique": { "name": "spheres_handle_unique", - "columns": [ - "handle" - ], + "columns": ["handle"], "isUnique": true } }, @@ -636,9 +587,7 @@ "indexes": { "idx_feature_request_comment_votes_comment": { "name": "idx_feature_request_comment_votes_comment", - "columns": [ - "comment_id" - ], + "columns": ["comment_id"], "isUnique": false } }, @@ -647,22 +596,15 @@ "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", "tableFrom": "feature_request_comment_votes", "tableTo": "feature_request_comments", - "columnsFrom": [ - "comment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": { "feature_request_comment_votes_comment_id_author_did_pk": { - "columns": [ - "comment_id", - "author_did" - ], + "columns": ["comment_id", "author_did"], "name": "feature_request_comment_votes_comment_id_author_did_pk" } }, @@ -733,17 +675,12 @@ "indexes": { "idx_feature_request_comments_request": { "name": "idx_feature_request_comments_request", - "columns": [ - "request_id" - ], + "columns": ["request_id"], "isUnique": false }, "idx_feature_request_comments_author_request": { "name": "idx_feature_request_comments_author_request", - "columns": [ - "author_did", - "request_id" - ], + "columns": ["author_did", "request_id"], "isUnique": false } }, @@ -752,12 +689,8 @@ "name": "feature_request_comments_request_id_feature_requests_id_fk", "tableFrom": "feature_request_comments", "tableTo": "feature_requests", - "columnsFrom": [ - "request_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["request_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -808,9 +741,7 @@ "indexes": { "idx_feature_request_statuses_request": { "name": "idx_feature_request_statuses_request", - "columns": [ - "request_id" - ], + "columns": ["request_id"], "isUnique": false } }, @@ -819,12 +750,8 @@ "name": "feature_request_statuses_request_id_feature_requests_id_fk", "tableFrom": "feature_request_statuses", "tableTo": "feature_requests", - "columnsFrom": [ - "request_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["request_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -869,9 +796,7 @@ "indexes": { "idx_feature_request_votes_request": { "name": "idx_feature_request_votes_request", - "columns": [ - "request_id" - ], + "columns": ["request_id"], "isUnique": false } }, @@ -880,22 +805,15 @@ "name": "feature_request_votes_request_id_feature_requests_id_fk", "tableFrom": "feature_request_votes", "tableTo": "feature_requests", - "columnsFrom": [ - "request_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["request_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": { "feature_request_votes_request_id_author_did_pk": { - "columns": [ - "request_id", - "author_did" - ], + "columns": ["request_id", "author_did"], "name": "feature_request_votes_request_id_author_did_pk" } }, @@ -1002,24 +920,17 @@ "indexes": { "idx_feature_requests_sphere_number": { "name": "idx_feature_requests_sphere_number", - "columns": [ - "sphere_id", - "number" - ], + "columns": ["sphere_id", "number"], "isUnique": true }, "idx_feature_requests_sphere": { "name": "idx_feature_requests_sphere", - "columns": [ - "sphere_id" - ], + "columns": ["sphere_id"], "isUnique": false }, "idx_feature_requests_status": { "name": "idx_feature_requests_status", - "columns": [ - "status" - ], + "columns": ["status"], "isUnique": false } }, @@ -1028,12 +939,8 @@ "name": "feature_requests_sphere_id_spheres_id_fk", "tableFrom": "feature_requests", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1092,9 +999,7 @@ "indexes": { "idx_feed_posts_parent": { "name": "idx_feed_posts_parent", - "columns": [ - "parent_id" - ], + "columns": ["parent_id"], "isUnique": false } }, @@ -1153,18 +1058,12 @@ "indexes": { "idx_kanban_columns_sphere_slug": { "name": "idx_kanban_columns_sphere_slug", - "columns": [ - "sphere_id", - "slug" - ], + "columns": ["sphere_id", "slug"], "isUnique": true }, "idx_kanban_columns_sphere_position": { "name": "idx_kanban_columns_sphere_position", - "columns": [ - "sphere_id", - "position" - ], + "columns": ["sphere_id", "position"], "isUnique": false } }, @@ -1173,12 +1072,8 @@ "name": "kanban_columns_sphere_id_spheres_id_fk", "tableFrom": "kanban_columns", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1251,17 +1146,12 @@ "indexes": { "idx_kanban_task_comments_task": { "name": "idx_kanban_task_comments_task", - "columns": [ - "task_id" - ], + "columns": ["task_id"], "isUnique": false }, "idx_kanban_task_comments_author_task": { "name": "idx_kanban_task_comments_author_task", - "columns": [ - "author_did", - "task_id" - ], + "columns": ["author_did", "task_id"], "isUnique": false } }, @@ -1270,12 +1160,8 @@ "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", "tableFrom": "kanban_task_comments", "tableTo": "kanban_tasks", - "columnsFrom": [ - "task_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["task_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1326,9 +1212,7 @@ "indexes": { "idx_kanban_task_status_changes_task": { "name": "idx_kanban_task_status_changes_task", - "columns": [ - "task_id" - ], + "columns": ["task_id"], "isUnique": false } }, @@ -1337,12 +1221,8 @@ "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", "tableFrom": "kanban_task_status_changes", "tableTo": "kanban_tasks", - "columnsFrom": [ - "task_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["task_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1460,33 +1340,22 @@ "indexes": { "idx_kanban_tasks_sphere_number": { "name": "idx_kanban_tasks_sphere_number", - "columns": [ - "sphere_id", - "number" - ], + "columns": ["sphere_id", "number"], "isUnique": true }, "idx_kanban_tasks_sphere": { "name": "idx_kanban_tasks_sphere", - "columns": [ - "sphere_id" - ], + "columns": ["sphere_id"], "isUnique": false }, "idx_kanban_tasks_status": { "name": "idx_kanban_tasks_status", - "columns": [ - "status" - ], + "columns": ["status"], "isUnique": false }, "idx_kanban_tasks_sphere_status_position": { "name": "idx_kanban_tasks_sphere_status_position", - "columns": [ - "sphere_id", - "status", - "position" - ], + "columns": ["sphere_id", "status", "position"], "isUnique": false } }, @@ -1495,12 +1364,8 @@ "name": "kanban_tasks_sphere_id_spheres_id_fk", "tableFrom": "kanban_tasks", "tableTo": "spheres", - "columnsFrom": [ - "sphere_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1520,4 +1385,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f773f6a..389910c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1776417674886, "tag": "0006_melodic_menace", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1776681600000, + "tag": "0007_label_pds_records", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/app/e2e/seed.ts b/packages/app/e2e/seed.ts index 7feecd7..76654b4 100644 --- a/packages/app/e2e/seed.ts +++ b/packages/app/e2e/seed.ts @@ -222,15 +222,7 @@ const MEMBER_DID = "did:plc:e2e-test-member"; db.run( `INSERT INTO feature_requests (id, sphere_id, number, title, description, status, author_did) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - fr.id, - "sphere-alpha", - fr.number, - fr.title, - fr.description, - fr.status, - fr.authorDid, - ], + [fr.id, "sphere-alpha", fr.number, fr.title, fr.description, fr.status, fr.authorDid], ); } @@ -287,15 +279,7 @@ const MEMBER_DID = "did:plc:e2e-test-member"; db.run( `INSERT INTO feature_requests (id, sphere_id, number, title, description, status, author_did) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - fr.id, - "sphere-beta", - fr.number, - fr.title, - fr.description, - fr.status, - fr.authorDid, - ], + [fr.id, "sphere-beta", fr.number, fr.title, fr.description, fr.status, fr.authorDid], ); } diff --git a/packages/app/src/pages/sphere-labels.tsx b/packages/app/src/pages/sphere-labels.tsx index 71677e2..d5f8fe4 100644 --- a/packages/app/src/pages/sphere-labels.tsx +++ b/packages/app/src/pages/sphere-labels.tsx @@ -272,88 +272,88 @@ function LabelsContent({ handle }: { handle: string }) { data-drag-item > {editingId.value === label.id ? ( -
-
- - (editName.value = (e.target as HTMLInputElement).value)} - /> -
-
- - - (editDescription.value = (e.target as HTMLInputElement).value) - } - /> -
-
- - (editColor.value = c)} - /> -
-
- - -
-
- ) : ( -
-
- +
- - {label.description && ( - - {label.description} - - )} + + (editName.value = (e.target as HTMLInputElement).value)} + /> +
+
+ + + (editDescription.value = (e.target as HTMLInputElement).value) + } + /> +
+
+ + (editColor.value = c)} + /> +
+
+ +
+ ) : (
- - {confirmingDeleteId.value === label.id ? ( - <> +
+ +
+ + {label.description && ( + + {label.description} + + )} +
+
+
+ + {confirmingDeleteId.value === label.id ? ( + <> + + + + ) : ( - - - ) : ( - - )} + )} +
-
- )} + )}
))} diff --git a/packages/core/src/db/migrate.ts b/packages/core/src/db/migrate.ts index 0c14a9c..65b43ff 100644 --- a/packages/core/src/db/migrate.ts +++ b/packages/core/src/db/migrate.ts @@ -84,4 +84,3 @@ function seedEntryCounters() { db.run("PRAGMA foreign_keys = ON;"); db.close(); } - diff --git a/packages/core/src/db/schema/index.ts b/packages/core/src/db/schema/index.ts index c51c32d..7b52db5 100644 --- a/packages/core/src/db/schema/index.ts +++ b/packages/core/src/db/schema/index.ts @@ -3,5 +3,5 @@ export type { Sphere, SphereMember, SphereModule, SpherePermission } from "./sph export { oauthStates, oauthSessions } from "./auth.ts"; export { indexerCursor } from "./cursor.ts"; export { sphereEntryCounter } from "./entry-counter.ts"; -export { sphereLabels, entityLabels } from "./labels.ts"; +export { sphereLabels, entityLabels, labelPdsRecords } from "./labels.ts"; export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts"; diff --git a/packages/core/src/db/schema/labels.ts b/packages/core/src/db/schema/labels.ts index 93d2de9..737d284 100644 --- a/packages/core/src/db/schema/labels.ts +++ b/packages/core/src/db/schema/labels.ts @@ -46,6 +46,18 @@ export const entityLabels = sqliteTable( ], ); +/** Tracks which PDS record rkey each user used for label assignments on a given entity. */ +export const labelPdsRecords = sqliteTable( + "label_pds_records", + { + entityId: text("entity_id").notNull(), + entityType: text("entity_type", { enum: ["feature-request", "kanban-task"] }).notNull(), + actorDid: text("actor_did").notNull(), + rkey: text("rkey").notNull(), + }, + (table) => [primaryKey({ columns: [table.entityId, table.entityType, table.actorDid] })], +); + export type SphereLabel = InferSelectModel; export type EntityLabel = InferSelectModel; export type EntityType = EntityLabel["entityType"]; diff --git a/packages/core/src/generated/lexicon-records.ts b/packages/core/src/generated/lexicon-records.ts index a60e47a..76fbc8c 100644 --- a/packages/core/src/generated/lexicon-records.ts +++ b/packages/core/src/generated/lexicon-records.ts @@ -107,6 +107,8 @@ export interface SphereLabelRecord { subject: string; /** Label names assigned to the entity. Replaces any previous set. */ labels: string[]; + /** datetime */ + updatedAt?: string; } export interface SphereMemberRecord { diff --git a/packages/core/src/sphere/index.ts b/packages/core/src/sphere/index.ts index 6ea0af5..d9fd3ec 100644 --- a/packages/core/src/sphere/index.ts +++ b/packages/core/src/sphere/index.ts @@ -18,6 +18,8 @@ export { getLabelsForEntities, setEntityLabels, resolveLabelIdsByName, + getOrCreateLabelRkey, + upsertLabelRkey, } from "./label-operations.ts"; export type { LabelInfo } from "./label-operations.ts"; export { setEntityLabelsSchema } from "./schemas.ts"; diff --git a/packages/core/src/sphere/label-operations.ts b/packages/core/src/sphere/label-operations.ts index 74bcd11..a7983e5 100644 --- a/packages/core/src/sphere/label-operations.ts +++ b/packages/core/src/sphere/label-operations.ts @@ -1,6 +1,6 @@ import { eq, and, max, inArray, asc, count } from "../db/drizzle.ts"; import { getDb } from "../db/index.ts"; -import { sphereLabels, entityLabels } from "../db/schema/index.ts"; +import { sphereLabels, entityLabels, labelPdsRecords } from "../db/schema/index.ts"; import type { SphereLabel, EntityType } from "../db/schema/index.ts"; import { generateRkey } from "../pds.ts"; @@ -134,6 +134,49 @@ export function getLabelsForEntities( return result; } +/** Get or create a stable rkey for a user's label PDS record on an entity. */ +export function getOrCreateLabelRkey( + entityId: string, + entityType: EntityType, + actorDid: string, +): string { + const db = getDb(); + const existing = db + .select({ rkey: labelPdsRecords.rkey }) + .from(labelPdsRecords) + .where( + and( + eq(labelPdsRecords.entityId, entityId), + eq(labelPdsRecords.entityType, entityType), + eq(labelPdsRecords.actorDid, actorDid), + ), + ) + .get(); + + if (existing) return existing.rkey; + + const rkey = generateRkey(); + db.insert(labelPdsRecords).values({ entityId, entityType, actorDid, rkey }).run(); + return rkey; +} + +/** Save a label PDS record rkey received from the network. */ +export function upsertLabelRkey( + entityId: string, + entityType: EntityType, + actorDid: string, + rkey: string, +): void { + getDb() + .insert(labelPdsRecords) + .values({ entityId, entityType, actorDid, rkey }) + .onConflictDoUpdate({ + target: [labelPdsRecords.entityId, labelPdsRecords.entityType, labelPdsRecords.actorDid], + set: { rkey }, + }) + .run(); +} + /** Replace all labels for an entity with the given label IDs. * Validates that every label belongs to the specified sphere. */ export function setEntityLabels( diff --git a/packages/core/src/sphere/operations.ts b/packages/core/src/sphere/operations.ts index 5645f91..a0d689f 100644 --- a/packages/core/src/sphere/operations.ts +++ b/packages/core/src/sphere/operations.ts @@ -2,6 +2,7 @@ import { eq, and, like } from "drizzle-orm"; import { getDb } from "../db/index.ts"; import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts"; import { parseAtUri } from "../indexer/uri.ts"; +import { tidToDate } from "../pds.ts"; import { ROLES } from "../permissions/roles.ts"; import { checkPermission } from "../permissions/check.ts"; @@ -302,7 +303,8 @@ export type LabelHandler = ( subjectUri: string, labelNames: string[], actorDid: string, - tid: string, + rkey: string, + updatedAt: string, ) => boolean; const labelHandlers: LabelHandler[] = []; @@ -319,9 +321,10 @@ export function indexLabelAssignment( ): void { const subjectUri = record.subject as string; const labels = record.labels as string[]; + const updatedAt = (record.updatedAt as string) ?? tidToDate(rkey); if (!subjectUri || !Array.isArray(labels)) return; for (const handler of labelHandlers) { - if (handler(subjectUri, labels, did, rkey)) return; + if (handler(subjectUri, labels, did, rkey, updatedAt)) return; } } diff --git a/packages/feature-requests/src/api/requests.ts b/packages/feature-requests/src/api/requests.ts index fcdf0d7..4221ebf 100644 --- a/packages/feature-requests/src/api/requests.ts +++ b/packages/feature-requests/src/api/requests.ts @@ -21,6 +21,7 @@ import { getLabelsForEntities, setEntityLabels, setEntityLabelsSchema, + getOrCreateLabelRkey, } from "@exosphere/core/sphere"; const COLLECTION = "site.exosphere.featureRequest.entry"; @@ -201,14 +202,19 @@ app.post("/", requireAuth, requirePermission("feature-requests", "create"), asyn // Write label PDS record if labels were set and entity has a PDS URI if (pdsUri && labels.length > 0) { const session = c.var.session; - const labelTid = generateRkey(); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { + const now = new Date().toISOString(); + const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { subject: pdsUri, labels: labels.map((l) => l.name), + updatedAt: now, }); if (labelPdsUri) { const db = getDb(); - db.update(featureRequests).set({ labelTid }).where(eq(featureRequests.id, id)).run(); + db.update(featureRequests) + .set({ labelUpdatedAt: now }) + .where(eq(featureRequests.id, id)) + .run(); } } @@ -438,16 +444,21 @@ app.post("/:id/labels", requireAuth, async (c) => { setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds); const labels = getLabelsForEntities([id], "feature-request").get(id) ?? []; - // Write label PDS record + // Write label PDS record (reuse existing rkey if this user already wrote one) if (c.var.sphereVisibility === "public" && existing.pdsUri) { const session = c.var.session; - const labelTid = generateRkey(); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { + const now = new Date().toISOString(); + const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { subject: existing.pdsUri, labels: labels.map((l) => l.name), + updatedAt: now, }); if (labelPdsUri) { - db.update(featureRequests).set({ labelTid }).where(eq(featureRequests.id, id)).run(); + db.update(featureRequests) + .set({ labelUpdatedAt: now }) + .where(eq(featureRequests.id, id)) + .run(); } } diff --git a/packages/feature-requests/src/db/schema.ts b/packages/feature-requests/src/db/schema.ts index 5e4d29e..eba02fc 100644 --- a/packages/feature-requests/src/db/schema.ts +++ b/packages/feature-requests/src/db/schema.ts @@ -29,7 +29,7 @@ export const featureRequests = sqliteTable( pdsUri: text("pds_uri"), hiddenAt: text("hidden_at"), moderatedBy: text("moderated_by"), - labelTid: text("label_tid"), + labelUpdatedAt: text("label_updated_at"), updatedAt: text("updated_at") .notNull() .default(sql`(datetime('now'))`), diff --git a/packages/feature-requests/src/indexer.ts b/packages/feature-requests/src/indexer.ts index 9452e31..8a79a1b 100644 --- a/packages/feature-requests/src/indexer.ts +++ b/packages/feature-requests/src/indexer.ts @@ -6,6 +6,7 @@ import { getActiveMemberRole, setEntityLabels, resolveLabelIdsByName, + upsertLabelRkey, } from "@exosphere/core/sphere"; import { checkPermission } from "@exosphere/core/permissions"; import { getDb } from "@exosphere/core/db"; @@ -38,7 +39,7 @@ const STATUS_COLLECTION = "site.exosphere.featureRequest.status"; registerModerationHandler(handleFeatureRequestModeration); // Register label handler: resolve label PDS records targeting feature requests -registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { +registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => { const parsed = parseAtUri(subjectUri); if (!parsed || parsed.collection !== COLLECTION) return false; @@ -48,15 +49,15 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { id: featureRequests.id, sphereId: featureRequests.sphereId, authorDid: featureRequests.authorDid, - labelTid: featureRequests.labelTid, + labelUpdatedAt: featureRequests.labelUpdatedAt, }) .from(featureRequests) .where(eq(featureRequests.id, parsed.rkey)) .get(); if (!fr) return false; - // TID-based ordering: skip if we already have a newer label record - if (fr.labelTid && fr.labelTid >= tid) return true; + // Timestamp-based ordering: skip if we already have a newer label record + if (fr.labelUpdatedAt && fr.labelUpdatedAt >= updatedAt) return true; // Permission check: author can always set labels, otherwise need changeStatus if (fr.authorDid !== actorDid) { @@ -66,7 +67,11 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { const labelIds = resolveLabelIdsByName(fr.sphereId, labelNames); setEntityLabels(fr.sphereId, fr.id, "feature-request", labelIds); - db.update(featureRequests).set({ labelTid: tid }).where(eq(featureRequests.id, fr.id)).run(); + upsertLabelRkey(fr.id, "feature-request", actorDid, rkey); + db.update(featureRequests) + .set({ labelUpdatedAt: updatedAt }) + .where(eq(featureRequests.id, fr.id)) + .run(); return true; }); diff --git a/packages/feature-requests/src/types.ts b/packages/feature-requests/src/types.ts index ef1c082..949039d 100644 --- a/packages/feature-requests/src/types.ts +++ b/packages/feature-requests/src/types.ts @@ -1,10 +1,6 @@ export type { FeatureRequest, FeatureRequestComment, FeatureRequestStatus } from "./db/schema.ts"; export type { Status, SettableStatus } from "./schemas/feature-request.ts"; -export { - statuses, - settableStatuses, - statusLabels, -} from "./schemas/feature-request.ts"; +export { statuses, settableStatuses, statusLabels } from "./schemas/feature-request.ts"; import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts"; import type { LabelInfo } from "@exosphere/core/sphere"; diff --git a/packages/kanban/src/api/tasks.ts b/packages/kanban/src/api/tasks.ts index 437796a..b1e2c50 100644 --- a/packages/kanban/src/api/tasks.ts +++ b/packages/kanban/src/api/tasks.ts @@ -32,6 +32,7 @@ import { getLabelsForEntities, setEntityLabels, setEntityLabelsSchema, + getOrCreateLabelRkey, } from "@exosphere/core/sphere"; const COLLECTION = "site.exosphere.kanban.entry"; @@ -220,14 +221,16 @@ app.post("/", requireAuth, requirePermission(MODULE, "create"), async (c) => { // Write label PDS record if labels were set and entity has a PDS URI if (pdsUri && labels.length > 0) { const session = c.var.session; - const labelTid = generateRkey(); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { + const now = new Date().toISOString(); + const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { subject: pdsUri, labels: labels.map((l) => l.name), + updatedAt: now, }); if (labelPdsUri) { const db = getDb(); - db.update(kanbanTasks).set({ labelTid }).where(eq(kanbanTasks.id, id)).run(); + db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); } } @@ -616,16 +619,18 @@ app.post("/:id/labels", requireAuth, async (c) => { setEntityLabels(sphereId, id, "kanban-task", parsed.data.labelIds); const labels = getLabelsForEntities([id], "kanban-task").get(id) ?? []; - // Write label PDS record + // Write label PDS record (reuse existing rkey if this user already wrote one) if (c.var.sphereVisibility === "public" && existing.pdsUri) { const session = c.var.session; - const labelTid = generateRkey(); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelTid, { + const now = new Date().toISOString(); + const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); + const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { subject: existing.pdsUri, labels: labels.map((l) => l.name), + updatedAt: now, }); if (labelPdsUri) { - db.update(kanbanTasks).set({ labelTid }).where(eq(kanbanTasks.id, id)).run(); + db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); } } diff --git a/packages/kanban/src/db/schema.ts b/packages/kanban/src/db/schema.ts index 54caaed..571cb35 100644 --- a/packages/kanban/src/db/schema.ts +++ b/packages/kanban/src/db/schema.ts @@ -40,7 +40,7 @@ export const kanbanTasks = sqliteTable( pdsUri: text("pds_uri"), hiddenAt: text("hidden_at"), moderatedBy: text("moderated_by"), - labelTid: text("label_tid"), + labelUpdatedAt: text("label_updated_at"), updatedAt: text("updated_at") .notNull() .default(sql`(datetime('now'))`), diff --git a/packages/kanban/src/indexer.ts b/packages/kanban/src/indexer.ts index e5f028e..05553bf 100644 --- a/packages/kanban/src/indexer.ts +++ b/packages/kanban/src/indexer.ts @@ -6,6 +6,7 @@ import { getActiveMemberRole, setEntityLabels, resolveLabelIdsByName, + upsertLabelRkey, } from "@exosphere/core/sphere"; import { checkPermission } from "@exosphere/core/permissions"; import { getDb } from "@exosphere/core/db"; @@ -32,7 +33,7 @@ const STATUS_COLLECTION = "site.exosphere.kanban.status"; registerModerationHandler(handleKanbanModeration); // Register label handler: resolve label PDS records targeting kanban tasks -registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { +registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => { const parsed = parseAtUri(subjectUri); if (!parsed || parsed.collection !== COLLECTION) return false; @@ -42,15 +43,15 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { id: kanbanTasks.id, sphereId: kanbanTasks.sphereId, authorDid: kanbanTasks.authorDid, - labelTid: kanbanTasks.labelTid, + labelUpdatedAt: kanbanTasks.labelUpdatedAt, }) .from(kanbanTasks) .where(eq(kanbanTasks.id, parsed.rkey)) .get(); if (!task) return false; - // TID-based ordering: skip if we already have a newer label record - if (task.labelTid && task.labelTid >= tid) return true; + // Timestamp-based ordering: skip if we already have a newer label record + if (task.labelUpdatedAt && task.labelUpdatedAt >= updatedAt) return true; // Permission check: author can always set labels, otherwise need manageTasks if (task.authorDid !== actorDid) { @@ -60,7 +61,11 @@ registerLabelHandler((subjectUri, labelNames, actorDid, tid) => { const labelIds = resolveLabelIdsByName(task.sphereId, labelNames); setEntityLabels(task.sphereId, task.id, "kanban-task", labelIds); - db.update(kanbanTasks).set({ labelTid: tid }).where(eq(kanbanTasks.id, task.id)).run(); + upsertLabelRkey(task.id, "kanban-task", actorDid, rkey); + db.update(kanbanTasks) + .set({ labelUpdatedAt: updatedAt }) + .where(eq(kanbanTasks.id, task.id)) + .run(); return true; }); diff --git a/packages/kanban/src/ui/components/column-manager.tsx b/packages/kanban/src/ui/components/column-manager.tsx index fb6cdcb..b6bec55 100644 --- a/packages/kanban/src/ui/components/column-manager.tsx +++ b/packages/kanban/src/ui/components/column-manager.tsx @@ -156,81 +156,81 @@ export function ColumnManager({
- {editingId.value === col.id ? ( - (editLabel.value = (e.target as HTMLInputElement).value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleRename(col.id); - if (e.key === "Escape") editingId.value = null; - }} - autoFocus - /> - ) : ( - - {col.label} ({col.slug}) - - )} + {editingId.value === col.id ? ( + (editLabel.value = (e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(col.id); + if (e.key === "Escape") editingId.value = null; + }} + autoFocus + /> + ) : ( + + {col.label} ({col.slug}) + + )} - {editingId.value === col.id ? ( - <> + {editingId.value === col.id ? ( + <> + + + + ) : ( + <> + + {columns.length > 1 && ( + + )} + + )} +
+ + {deletingId.value === col.id && ( +
+ Move tasks to: + - - - ) : ( - <> - - {columns.length > 1 && ( - - )} - +
)} - - {deletingId.value === col.id && ( -
- Move tasks to: - - - -
- )} - ))} {dropIndex.value === columns.length &&
} diff --git a/packages/kanban/src/ui/ui.css.ts b/packages/kanban/src/ui/ui.css.ts index 28676ac..d8c31f5 100644 --- a/packages/kanban/src/ui/ui.css.ts +++ b/packages/kanban/src/ui/ui.css.ts @@ -155,7 +155,6 @@ export const detailContent = style({ gap: vars.space.xl, }); - export const titleRow = style({ display: "flex", alignItems: "center",