Add curator Movement links config with proof URLs and admin-gated delete votes.
Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2b4cdc5a9f
commit
1b760faf0d
36 files changed
+2051
-43
No files matched your search
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Movement predecessor → successor lineage (timeline branches / Tree of Art).
|
||||
* Curators manage rows with required proof URLs; defaults seed from scholarship.
|
||||
*/
|
||||
|
||||
const pool = require('./db');
|
||||
|
||||
/** @typedef {{ sourceName: string, targetName: string, linkYearApprox: number | null, proofUrl: string, proofLabel: string }} DefaultLink */
|
||||
|
||||
/** @type {ReadonlyArray<DefaultLink>} */
|
||||
const DEFAULT_MOVEMENT_LINEAGE = Object.freeze([
|
||||
{
|
||||
sourceName: 'Ancient Greek & Roman',
|
||||
targetName: 'Byzantine',
|
||||
linkYearApprox: 330,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/byza/hd_byza.htm',
|
||||
proofLabel: 'Met — Byzantine art',
|
||||
},
|
||||
{
|
||||
sourceName: 'Byzantine',
|
||||
targetName: 'Gothic',
|
||||
linkYearApprox: 1150,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/mgot/hd_mgot.htm',
|
||||
proofLabel: 'Met — Gothic art',
|
||||
},
|
||||
{
|
||||
sourceName: 'Gothic',
|
||||
targetName: 'Early Renaissance',
|
||||
linkYearApprox: 1400,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/elar/hd_elar.htm',
|
||||
proofLabel: 'Met — Florence & Early Renaissance',
|
||||
},
|
||||
{
|
||||
sourceName: 'Northern Renaissance',
|
||||
targetName: 'Early Renaissance',
|
||||
linkYearApprox: 1420,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/enet/hd_enet.htm',
|
||||
proofLabel: 'Met — Early Netherlandish painting',
|
||||
},
|
||||
{
|
||||
sourceName: 'Early Renaissance',
|
||||
targetName: 'High Renaissance',
|
||||
linkYearApprox: 1490,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/hiren/hd_hiren.htm',
|
||||
proofLabel: 'Met — High Renaissance',
|
||||
},
|
||||
{
|
||||
sourceName: 'High Renaissance',
|
||||
targetName: 'Mannerism',
|
||||
linkYearApprox: 1520,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/zino/hd_zino.htm',
|
||||
proofLabel: 'Met — Mannerism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Mannerism',
|
||||
targetName: 'Baroque',
|
||||
linkYearApprox: 1600,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/baro/hd_baro.htm',
|
||||
proofLabel: 'Met — Baroque Rome',
|
||||
},
|
||||
{
|
||||
sourceName: 'Baroque',
|
||||
targetName: 'Rococo',
|
||||
linkYearApprox: 1700,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
|
||||
proofLabel: 'Met — France 1600–1800 (Rococo)',
|
||||
},
|
||||
{
|
||||
sourceName: 'Rococo',
|
||||
targetName: 'Neoclassicism',
|
||||
linkYearApprox: 1760,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
|
||||
proofLabel: 'Met — France 1600–1800 (Neoclassicism)',
|
||||
},
|
||||
{
|
||||
sourceName: 'Neoclassicism',
|
||||
targetName: 'Romanticism',
|
||||
linkYearApprox: 1800,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/ht/10/euwf.html',
|
||||
proofLabel: 'Met — France 1800–1900',
|
||||
},
|
||||
{
|
||||
sourceName: 'Romanticism',
|
||||
targetName: 'Realism',
|
||||
linkYearApprox: 1840,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/gust/hd_gust.htm',
|
||||
proofLabel: 'Met — Gustave Courbet / Realism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Romanticism',
|
||||
targetName: 'Impressionism',
|
||||
linkYearApprox: 1860,
|
||||
proofUrl: 'https://www.theartstory.org/movement/impressionism/',
|
||||
proofLabel: 'TheArtStory — Impressionism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Realism',
|
||||
targetName: 'Impressionism',
|
||||
linkYearApprox: 1860,
|
||||
proofUrl: 'https://www.metmuseum.org/toah/hd/imml/hd_imml.htm',
|
||||
proofLabel: 'Met — Impressionism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Impressionism',
|
||||
targetName: 'Post-Impressionism',
|
||||
linkYearApprox: 1880,
|
||||
proofUrl: 'https://www.theartstory.org/movement/post-impressionism/',
|
||||
proofLabel: 'TheArtStory — Post-Impressionism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Post-Impressionism',
|
||||
targetName: 'Symbolism',
|
||||
linkYearApprox: 1880,
|
||||
proofUrl: 'https://www.theartstory.org/movement/symbolism/',
|
||||
proofLabel: 'TheArtStory — Symbolism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Post-Impressionism',
|
||||
targetName: 'Fauvism',
|
||||
linkYearApprox: 1904,
|
||||
proofUrl: 'https://www.theartstory.org/movement/fauvism/',
|
||||
proofLabel: 'TheArtStory — Fauvism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Post-Impressionism',
|
||||
targetName: 'Expressionism',
|
||||
linkYearApprox: 1905,
|
||||
proofUrl: 'https://www.theartstory.org/movement/expressionism/',
|
||||
proofLabel: 'TheArtStory — Expressionism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Post-Impressionism',
|
||||
targetName: 'Cubism',
|
||||
linkYearApprox: 1907,
|
||||
proofUrl: 'https://www.metmuseum.org/essays/cubism',
|
||||
proofLabel: 'Met — Cubism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Symbolism',
|
||||
targetName: 'Art Nouveau',
|
||||
linkYearApprox: 1890,
|
||||
proofUrl: 'https://www.theartstory.org/movement/art-nouveau/',
|
||||
proofLabel: 'TheArtStory — Art Nouveau',
|
||||
},
|
||||
{
|
||||
sourceName: 'Fauvism',
|
||||
targetName: 'Cubism',
|
||||
linkYearApprox: 1907,
|
||||
proofUrl:
|
||||
'https://www.christies.com/en/stories/from-fauvism-to-cubism-bc026df2c6774575938a4e26cacb8259',
|
||||
proofLabel: "Christie's — From Fauvism to Cubism",
|
||||
},
|
||||
{
|
||||
sourceName: 'Cubism',
|
||||
targetName: 'Futurism',
|
||||
linkYearApprox: 1909,
|
||||
proofUrl: 'https://www.theartstory.org/movement/futurism/',
|
||||
proofLabel: 'TheArtStory — Futurism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Cubism',
|
||||
targetName: 'Suprematism',
|
||||
linkYearApprox: 1913,
|
||||
proofUrl: 'https://www.theartstory.org/movement/suprematism/',
|
||||
proofLabel: 'TheArtStory — Suprematism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Cubism',
|
||||
targetName: 'Constructivism',
|
||||
linkYearApprox: 1913,
|
||||
proofUrl: 'https://www.theartstory.org/movement/constructivism/',
|
||||
proofLabel: 'TheArtStory — Constructivism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Expressionism',
|
||||
targetName: 'Abstract Expressionism',
|
||||
linkYearApprox: 1940,
|
||||
proofUrl: 'https://www.theartstory.org/movement/abstract-expressionism/',
|
||||
proofLabel: 'TheArtStory — Abstract Expressionism',
|
||||
},
|
||||
{
|
||||
sourceName: 'Dada',
|
||||
targetName: 'Surrealism',
|
||||
linkYearApprox: 1924,
|
||||
proofUrl: 'https://www.theartstory.org/movement/dada/',
|
||||
proofLabel: 'TheArtStory — Dada',
|
||||
},
|
||||
{
|
||||
sourceName: 'Surrealism',
|
||||
targetName: 'Abstract Expressionism',
|
||||
linkYearApprox: 1940,
|
||||
proofUrl: 'https://www.theartstory.org/artist/breton-andre/',
|
||||
proofLabel: 'TheArtStory — André Breton',
|
||||
},
|
||||
{
|
||||
sourceName: 'Abstract Expressionism',
|
||||
targetName: 'Pop Art',
|
||||
linkYearApprox: 1955,
|
||||
proofUrl: 'https://www.theartstory.org/movement/pop-art/',
|
||||
proofLabel: 'TheArtStory — Pop Art',
|
||||
},
|
||||
]);
|
||||
|
||||
function isValidProofUrl(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveId(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
function parseOptionalYear(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return NaN;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} body
|
||||
* @returns {{ ok: true, value: object } | { ok: false, error: string }}
|
||||
*/
|
||||
function validateCreatePayload(body) {
|
||||
const raw = body && typeof body === 'object' ? body : {};
|
||||
const sourceMovementId = parsePositiveId(raw.sourceMovementId);
|
||||
const targetMovementId = parsePositiveId(raw.targetMovementId);
|
||||
if (!sourceMovementId) {
|
||||
return { ok: false, error: 'sourceMovementId is required' };
|
||||
}
|
||||
if (!targetMovementId) {
|
||||
return { ok: false, error: 'targetMovementId is required' };
|
||||
}
|
||||
if (sourceMovementId === targetMovementId) {
|
||||
return { ok: false, error: 'source and target must be different movements' };
|
||||
}
|
||||
const proofUrlRaw = typeof raw.proofUrl === 'string' ? raw.proofUrl.trim() : '';
|
||||
if (!isValidProofUrl(proofUrlRaw)) {
|
||||
return { ok: false, error: 'proofUrl must be a valid http(s) URL (required)' };
|
||||
}
|
||||
const linkYearApprox = parseOptionalYear(raw.linkYearApprox);
|
||||
if (Number.isNaN(linkYearApprox)) {
|
||||
return { ok: false, error: 'linkYearApprox must be a number when provided' };
|
||||
}
|
||||
const proofLabel =
|
||||
typeof raw.proofLabel === 'string' && raw.proofLabel.trim()
|
||||
? raw.proofLabel.trim()
|
||||
: null;
|
||||
const notes =
|
||||
typeof raw.notes === 'string' && raw.notes.trim() ? raw.notes.trim() : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
sourceMovementId,
|
||||
targetMovementId,
|
||||
proofUrl: proofUrlRaw,
|
||||
proofLabel,
|
||||
linkYearApprox,
|
||||
notes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MIN_DELETE_VOTE_REASON_LEN = 10;
|
||||
|
||||
/**
|
||||
* @param {unknown} body
|
||||
* @returns {{ ok: true, value: { reason: string } } | { ok: false, error: string }}
|
||||
*/
|
||||
function validateDeleteVotePayload(body) {
|
||||
const raw = body && typeof body === 'object' ? body : {};
|
||||
const reason = typeof raw.reason === 'string' ? raw.reason.trim() : '';
|
||||
if (!reason) {
|
||||
return { ok: false, error: 'reason is required' };
|
||||
}
|
||||
if (reason.length < MIN_DELETE_VOTE_REASON_LEN) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `reason must be at least ${MIN_DELETE_VOTE_REASON_LEN} characters`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value: { reason } };
|
||||
}
|
||||
|
||||
const LIST_SELECT = `
|
||||
SELECT ml.id,
|
||||
ml.source_movement_id,
|
||||
ml.target_movement_id,
|
||||
ml.link_year_approx,
|
||||
ml.proof_url,
|
||||
ml.proof_label,
|
||||
ml.notes,
|
||||
ml.created_at,
|
||||
ml.updated_at,
|
||||
s.name AS source_name,
|
||||
s.start_year AS source_start_year,
|
||||
t.name AS target_name,
|
||||
t.start_year AS target_start_year,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM movement_lineage_delete_votes v
|
||||
WHERE v.link_id = ml.id
|
||||
) AS delete_vote_count
|
||||
FROM movement_lineage_links ml
|
||||
JOIN art_movements s ON s.id = ml.source_movement_id
|
||||
JOIN art_movements t ON t.id = ml.target_movement_id
|
||||
`;
|
||||
|
||||
async function listLinks({ q, limit = 500, offset = 0, viewerUserId = null } = {}) {
|
||||
const params = [];
|
||||
const where = [];
|
||||
if (q && typeof q === 'string' && q.trim()) {
|
||||
params.push(`%${q.trim()}%`);
|
||||
const i = params.length;
|
||||
where.push(`(
|
||||
s.name ILIKE $${i} OR t.name ILIKE $${i}
|
||||
OR ml.proof_url ILIKE $${i} OR COALESCE(ml.proof_label, '') ILIKE $${i}
|
||||
OR COALESCE(ml.notes, '') ILIKE $${i}
|
||||
)`);
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
params.push(Math.min(1000, Math.max(1, Number(limit) || 500)));
|
||||
const limitIdx = params.length;
|
||||
params.push(Math.max(0, Number(offset) || 0));
|
||||
const offsetIdx = params.length;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`${LIST_SELECT}
|
||||
${whereSql}
|
||||
ORDER BY COALESCE(ml.link_year_approx, t.start_year) ASC NULLS LAST, s.name, t.name
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const countParams = params.slice(0, -2);
|
||||
const { rows: countRows } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n
|
||||
FROM movement_lineage_links ml
|
||||
JOIN art_movements s ON s.id = ml.source_movement_id
|
||||
JOIN art_movements t ON t.id = ml.target_movement_id
|
||||
${whereSql}`,
|
||||
countParams,
|
||||
);
|
||||
|
||||
let items = rows;
|
||||
if (viewerUserId && rows.length) {
|
||||
const ids = rows.map((r) => r.id);
|
||||
const { rows: myVotes } = await pool.query(
|
||||
`SELECT link_id FROM movement_lineage_delete_votes
|
||||
WHERE user_id = $1 AND link_id = ANY($2::int[])`,
|
||||
[viewerUserId, ids],
|
||||
);
|
||||
const voted = new Set(myVotes.map((v) => v.link_id));
|
||||
items = rows.map((r) => ({
|
||||
...r,
|
||||
viewer_has_delete_vote: voted.has(r.id),
|
||||
}));
|
||||
} else {
|
||||
items = rows.map((r) => ({ ...r, viewer_has_delete_vote: false }));
|
||||
}
|
||||
|
||||
return { items, total: countRows[0]?.n ?? 0 };
|
||||
}
|
||||
|
||||
async function createLink(payload, { userId } = {}) {
|
||||
const validated = validateCreatePayload(payload);
|
||||
if (!validated.ok) {
|
||||
const err = new Error(validated.error);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
const v = validated.value;
|
||||
|
||||
const movements = await pool.query(
|
||||
`SELECT id FROM art_movements WHERE id = ANY($1::int[])`,
|
||||
[[v.sourceMovementId, v.targetMovementId]],
|
||||
);
|
||||
if (movements.rows.length !== 2) {
|
||||
const err = new Error('source or target movement not found');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO movement_lineage_links
|
||||
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label, notes, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[
|
||||
v.sourceMovementId,
|
||||
v.targetMovementId,
|
||||
v.linkYearApprox,
|
||||
v.proofUrl,
|
||||
v.proofLabel,
|
||||
v.notes,
|
||||
userId || null,
|
||||
],
|
||||
);
|
||||
const { rows: full } = await pool.query(`${LIST_SELECT} WHERE ml.id = $1`, [rows[0].id]);
|
||||
return full[0];
|
||||
} catch (e) {
|
||||
if (e && e.code === '23505') {
|
||||
const err = new Error('A link between these movements already exists');
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLink(id) {
|
||||
const linkId = parsePositiveId(id);
|
||||
if (!linkId) {
|
||||
const err = new Error('Invalid link id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`DELETE FROM movement_lineage_links WHERE id = $1 RETURNING id`,
|
||||
[linkId],
|
||||
);
|
||||
if (!rows.length) {
|
||||
const err = new Error('Link not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
return { id: linkId };
|
||||
}
|
||||
|
||||
async function listDeleteVotes(linkId) {
|
||||
const id = parsePositiveId(linkId);
|
||||
if (!id) {
|
||||
const err = new Error('Invalid link id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
const { rows: linkRows } = await pool.query(
|
||||
`SELECT id FROM movement_lineage_links WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!linkRows.length) {
|
||||
const err = new Error('Link not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT v.id, v.link_id, v.user_id, v.reason, v.created_at, u.username
|
||||
FROM movement_lineage_delete_votes v
|
||||
JOIN users u ON u.id = v.user_id
|
||||
WHERE v.link_id = $1
|
||||
ORDER BY v.created_at ASC`,
|
||||
[id],
|
||||
);
|
||||
return { linkId: id, votes: rows };
|
||||
}
|
||||
|
||||
async function castDeleteVote(linkId, body, { userId }) {
|
||||
const id = parsePositiveId(linkId);
|
||||
if (!id) {
|
||||
const err = new Error('Invalid link id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!userId) {
|
||||
const err = new Error('Curator login required');
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
const validated = validateDeleteVotePayload(body);
|
||||
if (!validated.ok) {
|
||||
const err = new Error(validated.error);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { rows: linkRows } = await pool.query(
|
||||
`SELECT id FROM movement_lineage_links WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!linkRows.length) {
|
||||
const err = new Error('Link not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO movement_lineage_delete_votes (link_id, user_id, reason)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, link_id, user_id, reason, created_at`,
|
||||
[id, userId, validated.value.reason],
|
||||
);
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e && e.code === '23505') {
|
||||
const err = new Error('You already voted to delete this link');
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function withdrawDeleteVote(linkId, { userId }) {
|
||||
const id = parsePositiveId(linkId);
|
||||
if (!id || !userId) {
|
||||
const err = new Error('Invalid request');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`DELETE FROM movement_lineage_delete_votes
|
||||
WHERE link_id = $1 AND user_id = $2
|
||||
RETURNING id`,
|
||||
[id, userId],
|
||||
);
|
||||
if (!rows.length) {
|
||||
const err = new Error('Vote not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
return { id: rows[0].id, linkId: id };
|
||||
}
|
||||
|
||||
/** Insert default scholarship links when the table is empty and movements exist. */
|
||||
async function seedDefaultsIfEmpty() {
|
||||
const { rows: countRows } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM movement_lineage_links`,
|
||||
);
|
||||
if ((countRows[0]?.n ?? 0) > 0) return { inserted: 0, skipped: true };
|
||||
|
||||
const { rows: movements } = await pool.query(`SELECT id, name FROM art_movements`);
|
||||
if (!movements.length) return { inserted: 0, skipped: true, reason: 'no movements' };
|
||||
|
||||
const byName = new Map(movements.map((m) => [m.name, m.id]));
|
||||
let inserted = 0;
|
||||
let missing = 0;
|
||||
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
|
||||
const sourceId = byName.get(row.sourceName);
|
||||
const targetId = byName.get(row.targetName);
|
||||
if (!sourceId || !targetId) {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
const result = await pool.query(
|
||||
`INSERT INTO movement_lineage_links
|
||||
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (source_movement_id, target_movement_id) DO NOTHING
|
||||
RETURNING id`,
|
||||
[sourceId, targetId, row.linkYearApprox, row.proofUrl, row.proofLabel],
|
||||
);
|
||||
if (result.rows.length) inserted += 1;
|
||||
}
|
||||
return { inserted, missing };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MOVEMENT_LINEAGE,
|
||||
MIN_DELETE_VOTE_REASON_LEN,
|
||||
isValidProofUrl,
|
||||
validateCreatePayload,
|
||||
validateDeleteVotePayload,
|
||||
listLinks,
|
||||
createLink,
|
||||
deleteLink,
|
||||
listDeleteVotes,
|
||||
castDeleteVote,
|
||||
withdrawDeleteVote,
|
||||
seedDefaultsIfEmpty,
|
||||
};
|
||||
Reference in new issue
Block a user