Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e18cf05
feat: add sandbox upload endpoint; refs #147
elainefan331 Sep 15, 2026
e878fd5
feat: upload to zodiac sandbox via timestamp update handler
elainefan331 Sep 15, 2026
becb535
feat(uploads): add authenticated POST /api/v1/uploads for JSON submis…
elainefan331 Sep 16, 2026
6550035
feat(uploads): add login-gated /upload page for JSON submissions; ref…
elainefan331 Sep 16, 2026
0e22f1f
style(uploads): improve UploadPage readability on the blue background…
elainefan331 Sep 16, 2026
2cb7abe
fix(uploads): add safe rate-limit key; refs #147
elainefan331 Sep 16, 2026
b336ebe
fix(uploads): strip reserved _-prefixed fields before writing; refs #147
elainefan331 Sep 16, 2026
44ab6ba
feat(uploads): add info note about review + reference id on UploadPag…
elainefan331 Sep 16, 2026
a1ebf4e
feat(uploads): submission workflow logic in createUpload; refs #147
elainefan331 Sep 21, 2026
f4e035d
feat(uploads): add submissions table + neurojson_dataset_seq; refs
elainefan331 Sep 21, 2026
bf92a4d
feat(uploads): support dataset updates + confirm flow on /upload page…
elainefan331 Sep 21, 2026
73e90a7
feat(uploads): add dashboard Uploads tab with status list + update ac…
elainefan331 Sep 21, 2026
18631ae
feat(uploads): add dataset name input on /upload page; refs #147
elainefan331 Sep 21, 2026
d88eceb
fix(uploads): simplify dataset-name-required error message; refs #147
elainefan331 Sep 21, 2026
280398f
feat(uploads): clarify + restyle the resubmit confirm dialog; refs#147
elainefan331 Sep 22, 2026
1b93e46
feat(upload):add dataset registry, submissions, and submission commen…
elainefan331 Sep 23, 2026
50a4e8c
feat(uploads): redesign submission identity model (registry + cycles …
elainefan331 Sep 23, 2026
31ecae2
polish(uploads): refine preferred dataset ID field + purple field foc…
elainefan331 Sep 23, 2026
daca7b3
feat(uploads): add a dataset detail page with review discussion threa…
elainefan331 Sep 23, 2026
08653cf
feat(uploads): enforce unique preferred dataset ID; refs #147
elainefan331 Sep 23, 2026
f63bb56
fix(uploads): show the real error for a taken preferred ID; refs #147
elainefan331 Sep 25, 2026
051c09a
feat(uploads): let users choose a target database (default public); r…
elainefan331 Sep 25, 2026
6d9b488
polish(uploads): tidy the /upload form fields; refs #147
elainefan331 Sep 25, 2026
6fbf04f
feat(uploads): copy button for internal reference + view-status link;…
elainefan331 Sep 25, 2026
c438d3d
feat(uploads): view submitted JSON via owner-only proxy; stop leaking…
elainefan331 Sep 25, 2026
5fcd166
feat(uploads): add draft workflow columns to submissions; refs #147
elainefan331 Sep 28, 2026
9c9ffbf
feat(uploads): add draft workflow with submit and withdraw; refs #147
elainefan331 Sep 28, 2026
02ea42d
feat(uploads): edit publishing settings on the upload detail page; re…
elainefan331 Sep 28, 2026
0d20792
fix(uploads): remove the internal ID field from the upload form; refs…
elainefan331 Sep 29, 2026
5858296
refactor(uploads): replace Update with Open on the Uploads tab; refs …
elainefan331 Sep 29, 2026
893f58b
feat(uploads): resubmit reminder and stricter name validation; refs #147
elainefan331 Sep 29, 2026
50d6c69
feat(uploads): add raw data tables (raw_objects, submission_raw_files…
elainefan331 Sep 30, 2026
8b606d4
feat(uploads): Ed25519 upload tokens and HMAC service signatures; ref…
elainefan331 Sep 30, 2026
da6a649
feat(uploads): add raw ZIP upload session routes; refs #147
elainefan331 Sep 30, 2026
508732b
feat(uploads): Zodiac storage events and raw upload completion; refs …
elainefan331 Oct 1, 2026
5ec8a47
feat(storage-api): skeleton for the Zodiac storage service; refs #147
elainefan331 Oct 2, 2026
0bd5225
feat(storage-api): tus resumable uploads with token checks; refs #147
elainefan331 Oct 2, 2026
fb8986d
feat(storage-api): finish uploads via a crash-safe outbox; refs #147
elainefan331 Oct 2, 2026
790c9bd
feat(storage-api): internal endpoints for REN (pull mode + monitoring…
elainefan331 Oct 2, 2026
86a59ae
feat(uploads): reconciliation job that finishes raw uploads by pullin…
elainefan331 Oct 2, 2026
34e321c
feat(uploads): frontend service for raw ZIP uploads; refs #147
elainefan331 Oct 2, 2026
0d5fc0a
feat(uploads): browser raw ZIP uploader on tus-js-client; refs #147
elainefan331 Oct 2, 2026
a5851b9
feat(uploads): raw ZIP section on the upload detail page; refs #147
elainefan331 Oct 2, 2026
91a65ed
feat(uploads): raw ZIP chip on the Uploads tab; refs #147
elainefan331 Oct 2, 2026
888e01c
feat(uploads): 50 GB raw ZIP limit, sent to the page; refs #147
elainefan331 Oct 2, 2026
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ backend/.env.local
backend/.env.*.local
backend/*.sqlite
backend/*.db
!backend/package-lock.json
!backend/package-lock.json

#storage-api
storage-api/storage-dev/
!storage-api/package-lock.json
64 changes: 64 additions & 0 deletions backend/migrations/20260923192128-create-dataset-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// Permanent record for one logical dataset. `internal_id` is the stable
// identity for the dataset's whole lifetime and is also the sandbox CouchDB
// `_id`. `dataset_id` (final public id) is assigned only at promotion.
async up(queryInterface, Sequelize) {
// Sequence backing NeuroJSON-generated public ids (njds######). Consumed
// only at promotion when the user did not request a valid custom id.
await queryInterface.sequelize.query(
"CREATE SEQUENCE IF NOT EXISTS neurojson_dataset_seq START 1;"
);

await queryInterface.createTable("dataset_registry", {
internal_id: {
type: Sequelize.UUID,
primaryKey: true,
allowNull: false, // app-generated (crypto.randomUUID); also the sandbox _id
},
dataset_id: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true, // final public id, set at promotion (njds###### or custom)
},
requested_dataset_id: {
type: Sequelize.STRING(63),
allowNull: true, // user's preferred public id — a preference until promotion
},
dataset_name: {
type: Sequelize.STRING(255),
allowNull: true, // canonical logical-dataset name
},
owner_user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: { model: "users", key: "id" },
onDelete: "RESTRICT", // don't orphan a dataset's owner
onUpdate: "CASCADE",
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

await queryInterface.addIndex("dataset_registry", ["owner_user_id"], {
name: "idx_registry_owner",
});
},

async down(queryInterface) {
await queryInterface.dropTable("dataset_registry");
await queryInterface.sequelize.query(
"DROP SEQUENCE IF EXISTS neurojson_dataset_seq;"
);
},
};
75 changes: 75 additions & 0 deletions backend/migrations/20260923192138-create-submissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// One review workflow (cycle) for a logical dataset. Holds only the current
// status + review/promotion metadata; the reviewer<->submitter discussion
// lives in submission_comments. A dataset can have many cycles over time,
// but only ONE open one (see the partial unique index below).
async up(queryInterface, Sequelize) {
await queryInterface.createTable("submissions", {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false,
},
submission_id: {
type: Sequelize.UUID,
allowNull: false,
unique: true, // one review cycle; referenced by submission_comments
},
dataset_registry_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "dataset_registry", key: "internal_id" },
onDelete: "CASCADE", // removing the logical dataset removes its cycles
onUpdate: "CASCADE",
},
status: {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: "pending", // pending|changes_requested|approved|rejected|promoted
},
reviewed_by: {
type: Sequelize.INTEGER,
allowNull: true,
references: { model: "users", key: "id" },
onDelete: "SET NULL", // keep the cycle if a reviewer is removed
onUpdate: "CASCADE",
},
reviewed_at: { type: Sequelize.DATE, allowNull: true },
promoted_db: { type: Sequelize.STRING(255), allowNull: true },
promoted_at: { type: Sequelize.DATE, allowNull: true },
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

await queryInterface.addIndex("submissions", ["dataset_registry_id"], {
name: "idx_submissions_registry",
});
await queryInterface.addIndex("submissions", ["status"], {
name: "idx_submissions_status",
});

// At most one OPEN workflow per dataset. "Open" = still active:
// pending / changes_requested / approved (approved is awaiting promotion,
// so it must also block a new cycle). rejected/promoted are terminal, so a
// confirmed resubmit can insert a fresh pending row.
await queryInterface.sequelize.query(
"CREATE UNIQUE INDEX uq_one_open_per_dataset ON submissions (dataset_registry_id) WHERE status IN ('pending','changes_requested','approved');"
);
},

async down(queryInterface) {
await queryInterface.dropTable("submissions");
},
};
50 changes: 50 additions & 0 deletions backend/migrations/20260923192147-create-submission-comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// Chronological reviewer <-> submitter discussion for a submission cycle,
// one row per comment. This is the single source of review discussion
// (there is no review_note column). Author role is derived at read time
// (user_id == dataset_registry.owner_user_id => submitter, else reviewer).
async up(queryInterface, Sequelize) {
await queryInterface.createTable("submission_comments", {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false,
},
submission_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "submissions", key: "submission_id" },
onDelete: "CASCADE", // comments die with their cycle
onUpdate: "CASCADE",
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: { model: "users", key: "id" },
onDelete: "RESTRICT", // a comment must keep a real author
onUpdate: "CASCADE",
},
message: { type: Sequelize.TEXT, allowNull: false },
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

// Serves the read query: WHERE submission_id = ? ORDER BY created_at ASC.
await queryInterface.addIndex(
"submission_comments",
["submission_id", "created_at"],
{ name: "idx_comments_submission_created" }
);
},

async down(queryInterface) {
await queryInterface.dropTable("submission_comments");
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// At most one dataset may reserve a given preferred public id (first-come).
// Partial so the many NULL (no preference) rows don't collide. Backs up the
// controller's assertRequestedIdFree check against races.
async up(queryInterface) {
await queryInterface.sequelize.query(
"CREATE UNIQUE INDEX uq_requested_dataset_id ON dataset_registry (requested_dataset_id) WHERE requested_dataset_id IS NOT NULL;"
);
},

async down(queryInterface) {
await queryInterface.sequelize.query(
"DROP INDEX IF EXISTS uq_requested_dataset_id;"
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// The public database a dataset should be published to at promotion.
// A preference/reservation; defaults to "public" when the user picks nothing.
// Actual db is resolved/created at promotion (a new name is review-gated).
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("dataset_registry", "requested_db", {
type: Sequelize.STRING(63),
allowNull: false,
defaultValue: "public",
});
},

async down(queryInterface) {
await queryInterface.removeColumn("dataset_registry", "requested_db");
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// Draft workflow: review status starts at 'draft'; JSON upload state is
// tracked separately from review state; raw ZIP is optional per submission.
async up(queryInterface, Sequelize) {
// New cycles start as draft (not pending).
await queryInterface.changeColumn("submissions", "status", {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: "draft", // draft|pending|changes_requested|approved|rejected|promoted
});

// JSON component state (separate from review status).
await queryInterface.addColumn("submissions", "json_status", {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: "uploaded", // uploaded | failed
});
await queryInterface.addColumn("submissions", "json_uploaded_at", {
type: Sequelize.DATE,
allowNull: true,
});
await queryInterface.addColumn("submissions", "json_error", {
type: Sequelize.TEXT,
allowNull: true,
});

// Last time the user sent it to review (draft/changes_requested → pending).
await queryInterface.addColumn("submissions", "submitted_at", {
type: Sequelize.DATE,
allowNull: true,
});

// Raw ZIP is optional; required for submit only when this is true.
await queryInterface.addColumn("submissions", "raw_zip_expected", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});

// Existing rows already had their JSON uploaded.
await queryInterface.sequelize.query(
"UPDATE submissions SET json_uploaded_at = updated_at WHERE json_uploaded_at IS NULL;"
);

// One active cycle per dataset — now including draft.
await queryInterface.sequelize.query(
"DROP INDEX IF EXISTS uq_one_open_per_dataset;"
);
await queryInterface.sequelize.query(
"CREATE UNIQUE INDEX uq_one_open_per_dataset ON submissions (dataset_registry_id) WHERE status IN ('draft','pending','changes_requested','approved');"
);
},

async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(
"DROP INDEX IF EXISTS uq_one_open_per_dataset;"
);
await queryInterface.sequelize.query(
"CREATE UNIQUE INDEX uq_one_open_per_dataset ON submissions (dataset_registry_id) WHERE status IN ('pending','changes_requested','approved');"
);
await queryInterface.removeColumn("submissions", "raw_zip_expected");
await queryInterface.removeColumn("submissions", "submitted_at");
await queryInterface.removeColumn("submissions", "json_error");
await queryInterface.removeColumn("submissions", "json_uploaded_at");
await queryInterface.removeColumn("submissions", "json_status");
await queryInterface.changeColumn("submissions", "status", {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: "pending",
});
},
};
Loading
Loading