Add mandatory personal ledgers

This commit is contained in:
openclaw 2026-07-22 22:09:23 +08:00
parent 50dfafc1d2
commit 2db043cda6
16 changed files with 172 additions and 38 deletions

View File

@ -23,7 +23,8 @@ type SelectorLevel = "type" | "category" | "subcategory";
const props = defineProps<{
ledgerId: string;
ledgerName: string;
ledgerOptions: Array<{ id: string; name: string; color: string }>;
personalLedgerId: string;
ledgerOptions: Array<{ id: string; name: string; color: string; isPersonal: boolean }>;
}>();
const emit = defineEmits<{
@ -41,7 +42,7 @@ const selectorOpen = ref(false);
const selectorLevel = ref<SelectorLevel>("type");
const noteEditing = ref(false);
const ledgerPickerOpen = ref(false);
const selectedLedgerIds = ref([props.ledgerId]);
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
const axisOffset = ref(0);
let axisPointerId: number | null = null;
let axisDragStartY = 0;
@ -67,9 +68,14 @@ const displayAmount = computed(() =>
maximumFractionDigits: 2,
}).format(calculatedAmount.value),
);
const selectedLedgerLabel = computed(() => selectedLedgerIds.value.length === 1
? props.ledgerOptions.find((ledger) => ledger.id === selectedLedgerIds.value[0])?.name ?? props.ledgerName
: `${selectedLedgerIds.value.length} 个账本`);
const selectedLedgerLabel = computed(() => {
const visibleNames = props.ledgerOptions
.filter((ledger) => !ledger.isPersonal && selectedLedgerIds.value.includes(ledger.id))
.map((ledger) => ledger.name);
if (!visibleNames.length) return "个人账本";
const names = visibleNames.slice(0, 3).join(",");
return selectedLedgerIds.value.length > 1 ? `${names}${selectedLedgerIds.value.length} 个账本` : names;
});
const dateLabel = computed(() => {
const date = new Date(occurredAt.value);
const now = new Date();
@ -327,6 +333,7 @@ function openLedgerPicker() {
}
function toggleLedger(ledgerId: string) {
if (ledgerId === props.personalLedgerId) return;
if (selectedLedgerIds.value.includes(ledgerId)) {
if (selectedLedgerIds.value.length > 1) {
selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId);
@ -525,9 +532,9 @@ function saveEntry() {
<div class="quick-ledger-picker-handle"></div>
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
<div class="quick-ledger-options">
<button v-for="ledger in ledgerOptions" :key="ledger.id" type="button" :class="{ selected: selectedLedgerIds.includes(ledger.id) }" @click="toggleLedger(ledger.id)">
<button v-for="ledger in ledgerOptions" :key="ledger.id" type="button" :class="{ selected: selectedLedgerIds.includes(ledger.id), automatic: ledger.isPersonal }" :disabled="ledger.isPersonal" @click="toggleLedger(ledger.id)">
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
<strong>{{ ledger.name }}</strong>
<strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
</button>
</div>

View File

@ -16,7 +16,7 @@ const drawerOpen = ref(false);
const savedToast = ref(false);
const ledgerOptions = computed(() =>
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color })),
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color, isPersonal: ledger.isPersonal })),
);
onMounted(async () => {
@ -63,6 +63,7 @@ async function saveEntry(input: {
:ledger-id="ledgerStore.currentLedger.id"
:ledger-name="ledgerStore.currentLedger.name"
:ledger-options="ledgerOptions"
:personal-ledger-id="ledgerStore.personalLedger?.id ?? ''"
@close="drawerOpen = false"
@save="saveEntry"
/>

View File

@ -9,6 +9,7 @@ export type LedgerRecord = {
updatedAt: string;
archivedAt: string | null;
role?: "owner" | "member";
isPersonal: boolean;
};
export type UserPreference = {
@ -34,6 +35,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
createdAt: now,
updatedAt: now,
archivedAt: null,
isPersonal: true,
},
{
id: "travel-ledger",
@ -43,6 +45,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
createdAt: now,
updatedAt: now,
archivedAt: null,
isPersonal: false,
},
];
}

View File

@ -27,6 +27,9 @@ export const useLedgerStore = defineStore("ledgers", {
currentRole(): "owner" | "member" | null {
return this.currentLedger?.role ?? null;
},
personalLedger(state) {
return state.ledgers.find((ledger) => ledger.isPersonal) ?? null;
},
},
actions: {
async loadLedgers() {

View File

@ -695,7 +695,9 @@ input:focus-visible {
.quick-ledger-options > button:last-child { border-bottom:0; }
.quick-ledger-options > button > span { width:34px; height:34px; display:grid; place-items:center; border-radius:8px; color:#fff; }
.quick-ledger-options > button > strong { overflow:hidden; font-size:14px; text-overflow:ellipsis; white-space:nowrap; }
.quick-ledger-options > button > strong small { margin-left:8px; color:#82908c; font-size:11px; font-weight:500; }
.quick-ledger-options > button > svg { color:#087f72; }
.quick-ledger-options > button.automatic { cursor:default; opacity:.62; }
.quick-ledger-picker-done { width:100%; height:46px; display:flex; align-items:center; justify-content:center; gap:6px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
.amount-result {

View File

@ -104,7 +104,7 @@ onMounted(async () => {
function hydrate(value: LedgerEntry) {
entry.value = value;
amount.value = (value.amount / 100).toFixed(2);
ledgerIds.value = [...value.ledgerIds];
ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))];
note.value = value.note;
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
const path = findCategoryPath(value.type, value.categoryId);
@ -137,6 +137,7 @@ function chooseChild(category: Category) {
}
function toggleLedger(ledgerId: string) {
if (ledgerId === ledgerStore.personalLedger?.id) return;
if (ledgerIds.value.includes(ledgerId)) {
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
return;
@ -346,9 +347,9 @@ async function deleteEntry() {
<div class="quick-ledger-picker-handle"></div>
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
<div class="quick-ledger-options">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" :class="{ selected: ledgerIds.includes(ledger.id) }" @click="toggleLedger(ledger.id)">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" :class="{ selected: ledgerIds.includes(ledger.id), automatic: ledger.isPersonal }" :disabled="ledger.isPersonal" @click="toggleLedger(ledger.id)">
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
<strong>{{ ledger.name }}</strong>
<strong>{{ ledger.name }}<small v-if="ledger.isPersonal">自动记入</small></strong>
<Check v-if="ledgerIds.includes(ledger.id)" :size="19" />
</button>
</div>

View File

@ -41,7 +41,7 @@ async function save() {
</header>
<form class="settings-form" @submit.prevent="save">
<section>
<label><span>账本名称</span><input v-model="name" maxlength="24" required /></label>
<label><span>账本名称</span><input v-model="name" maxlength="24" :disabled="ledgerStore.currentLedger?.isPersonal" required /></label>
<label><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="HKD">港币 HKD</option></select></label>
</section>
<section class="color-setting">

View File

@ -236,11 +236,12 @@ async function shareLedger() {
<main class="app-shell">
<header class="ledger-header">
<div class="top-actions">
<button class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
<button v-if="!ledgerStore.currentLedger?.isPersonal" class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
<span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">
{{ member.name.slice(0, 1) }}
</span>
</button>
<span v-else aria-hidden="true"></span>
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
<ChevronDown :size="16" />
@ -255,7 +256,7 @@ async function shareLedger() {
<button class="ledger-more-scrim" type="button" aria-label="关闭更多菜单" @click="moreMenuOpen = false"></button>
<div class="ledger-more-menu" role="menu">
<button type="button" role="menuitem" @click="openLedgerSettings"><Settings :size="18" />设置</button>
<button type="button" role="menuitem" @click="openShareSheet"><Share2 :size="18" />分享</button>
<button v-if="!ledgerStore.currentLedger?.isPersonal" type="button" role="menuitem" @click="openShareSheet"><Share2 :size="18" />分享</button>
</div>
</div>
</Transition>

View File

@ -77,7 +77,7 @@ async function createLedger() {
<section class="ledger-list" aria-label="全部账本">
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" @click="selectLedger(ledger.id)">
<span class="ledger-list-icon" :style="{ color: ledger.color, background: `${ledger.color}18` }"><LibraryBig :size="22" /></span>
<span><strong>{{ ledger.name }}</strong><small>{{ entryCounts.get(ledger.id) ?? 0 }} 笔流水 · {{ ledger.defaultCurrency }}</small></span>
<span><strong>{{ ledger.name }}</strong><small>{{ entryCounts.get(ledger.id) ?? 0 }} 笔流水 · {{ ledger.isPersonal ? "仅自己可见" : ledger.defaultCurrency }}</small></span>
<Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
<ChevronRight v-else :size="19" />
</button>

View File

@ -10,13 +10,22 @@ PUBLIC_ORIGIN=https://cents.example.com npm run bootstrap --workspace @cents/api
应用邀请使用 256 位随机 `key`,数据库只保存 SHA-256 摘要。链接固定 7 天过期并且只能创建一个账号。
管理员也可以直接添加用户:
```bash
npm run user:add -- '用户名' '至少8位的密码'
```
用户与个人账本在同一数据库事务内创建;用户名已存在或任一步失败时都不会留下半成品数据。
新用户打开 `/invite?key=...` 后填写姓名和密码:
- 用户 ID 由服务端生成 UUID。
- 姓名同时作为登录名,忽略首尾空格和大小写后必须唯一。
- 密码长度为 8 至 128 个字符。
- 密码使用 Argon2id 保存,参数为 19 MiB 内存、2 次迭代、并行度 1。
- 新账号自动获得一个自己的默认账本,并成为 owner。
- 新账号自动获得唯一的“个人账本”,并成为 owner。
- 个人账本不能分享或加入其他成员,每笔账目都会由服务端强制关联到其所有者的个人账本。
已登录用户打开应用邀请时直接返回应用,不创建账号,也不消费邀请。
@ -56,6 +65,7 @@ NEW_PASSWORD='新的密码' npm run password:reset --workspace @cents/api -- '
- 用户只能访问自己是成员的账本。
- `owner` 可以修改账本设置并创建账本邀请。
- 个人账本名称固定,且不能创建账本邀请。
- `member` 可以查看和记账,但不能创建邀请或修改账本设置。
- 服务端在每次请求时查询成员关系,不依赖前端隐藏按钮。
- 所有写请求检查 `Origin`API 同时启用安全响应头和请求限速。

View File

@ -13,6 +13,7 @@
"dev:web": "npm run dev --workspace @cents/web",
"dev:web:tunnel": "npm run dev --workspace @cents/web -- --port 6064",
"dev:api": "npm run dev --workspace @cents/api",
"user:add": "npm run user:add --workspace @cents/api --",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present"

View File

@ -7,6 +7,7 @@
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"bootstrap": "tsx src/bootstrap.ts",
"user:add": "tsx src/add-user.ts",
"password:reset": "tsx src/reset-password.ts",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit"

View File

@ -0,0 +1,28 @@
import type { PoolClient } from "pg";
import { createId, hashPassword, normalizeName, validPassword } from "./security.js";
export const PERSONAL_LEDGER_NAME = "个人账本";
export async function createUserWithPersonalLedger(client: PoolClient, name: string, password: string) {
const trimmedName = name.trim();
if (!trimmedName || trimmedName.length > 40) throw new Error("用户名应为 1 至 40 个字符");
if (!validPassword(password)) throw new Error("密码应为 8 至 128 个字符");
const user = { id: createId(), name: trimmedName };
const passwordHash = await hashPassword(password);
await client.query(
"INSERT INTO users (id, name, name_key, password_hash) VALUES ($1, $2, $3, $4)",
[user.id, user.name, normalizeName(user.name), passwordHash],
);
const ledgerId = createId();
await client.query(
`INSERT INTO ledgers (id, name, color, default_currency, personal_owner_id)
VALUES ($1, $2, '#087f72', 'CNY', $3)`,
[ledgerId, PERSONAL_LEDGER_NAME, user.id],
);
await client.query(
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
[ledgerId, user.id],
);
return user;
}

View File

@ -0,0 +1,26 @@
import { createUserWithPersonalLedger } from "./accounts.js";
import { initializeDatabase, pool } from "./db.js";
const name = process.argv[2]?.trim() ?? "";
const password = process.argv[3] ?? "";
if (!name || !password) {
throw new Error("用法npm run user:add -- <用户名> <密码>");
}
await initializeDatabase();
const client = await pool.connect();
try {
await client.query("BEGIN");
const user = await createUserWithPersonalLedger(client, name, password);
await client.query("COMMIT");
process.stdout.write(`已添加用户 ${user.name},并创建个人账本。\n`);
} catch (error) {
await client.query("ROLLBACK");
if (typeof error === "object" && error !== null && "code" in error && error.code === "23505") {
throw new Error("该用户名已存在");
}
throw error;
} finally {
client.release();
await pool.end();
}

View File

@ -1,4 +1,5 @@
import pg from "pg";
import { createId } from "./security.js";
const { Pool } = pg;
@ -32,6 +33,10 @@ CREATE TABLE IF NOT EXISTS ledgers (
archived_at timestamptz
);
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS personal_owner_id text REFERENCES users(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS ledgers_personal_owner_unique
ON ledgers (personal_owner_id) WHERE personal_owner_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS ledger_members (
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -149,4 +154,38 @@ CREATE TABLE IF NOT EXISTS entry_sync_operations (
export async function initializeDatabase() {
await pool.query(schema);
const client = await pool.connect();
try {
await client.query("BEGIN");
const users = await client.query<{ id: string }>(
`SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM ledgers l WHERE l.personal_owner_id = u.id)
FOR UPDATE`,
);
for (const user of users.rows) {
const ledgerId = createId();
await client.query(
`INSERT INTO ledgers (id, name, color, default_currency, personal_owner_id)
VALUES ($1, '个人账本', '#087f72', 'CNY', $2)`,
[ledgerId, user.id],
);
await client.query(
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
[ledgerId, user.id],
);
}
await client.query(
`INSERT INTO entry_ledgers (entry_id, ledger_id)
SELECT e.id, l.id FROM entries e JOIN ledgers l ON l.personal_owner_id = e.owner_id
ON CONFLICT (entry_id, ledger_id) DO UPDATE
SET unlinked_at = NULL, updated_at = now()
WHERE entry_ledgers.unlinked_at IS NOT NULL`,
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}

View File

@ -3,6 +3,7 @@ import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
import { initializeDatabase, pool } from "./db.js";
import {
clearSessionCookie,
@ -232,10 +233,11 @@ server.get("/api/ledgers", async (request, reply) => {
const result = await pool.query(
`SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency",
l.created_at AS "createdAt", l.updated_at AS "updatedAt",
l.archived_at AS "archivedAt", m.role
l.archived_at AS "archivedAt", m.role,
(l.personal_owner_id IS NOT NULL) AS "isPersonal"
FROM ledgers l JOIN ledger_members m ON m.ledger_id = l.id
WHERE m.user_id = $1 AND m.removed_at IS NULL AND l.archived_at IS NULL
ORDER BY l.updated_at DESC`,
ORDER BY (l.personal_owner_id = $1) DESC, l.updated_at DESC`,
[user.id],
);
return { ledgers: result.rows };
@ -304,6 +306,10 @@ server.patch<{
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
return reply.code(403).send({ error: "只有账本拥有者可以修改设置" });
}
const personalLedger = await pool.query<{ isPersonal: boolean }>(
`SELECT (personal_owner_id IS NOT NULL) AS "isPersonal" FROM ledgers WHERE id = $1`,
[request.params.ledgerId],
);
const name = request.body.name?.trim() ?? "";
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" });
const result = await pool.query(
@ -311,7 +317,7 @@ server.patch<{
WHERE id = $4
RETURNING id, name, color, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
[name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId],
[personalLedger.rows[0]?.isPersonal ? PERSONAL_LEDGER_NAME : name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId],
);
return { ledger: result.rows[0] };
});
@ -341,8 +347,6 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string
const password = request.body.password ?? "";
if (!name || name.length > 40) return reply.code(400).send({ error: "请填写 1 至 40 个字符的姓名" });
if (!validPassword(password)) return reply.code(400).send({ error: "密码应为 8 至 128 个字符" });
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
@ -361,17 +365,7 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string
return reply.code(code).send({ error: status === "accepted" ? "邀请已被使用" : status === "expired" ? "邀请已过期" : status === "revoked" ? "邀请已撤销" : "邀请链接无效" });
}
const user: User = { id: createId(), name };
await client.query(
"INSERT INTO users (id, name, name_key, password_hash) VALUES ($1, $2, $3, $4)",
[user.id, user.name, normalizeName(user.name), passwordHash],
);
const ledgerId = createId();
await client.query("INSERT INTO ledgers (id, name) VALUES ($1, '家庭日常')", [ledgerId]);
await client.query(
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
[ledgerId, user.id],
);
const user = await createUserWithPersonalLedger(client, name, password);
await client.query(
"UPDATE app_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
[user.id, invitation!.id],
@ -397,6 +391,8 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string
server.post<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/invitations", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
const personalLedger = await pool.query("SELECT 1 FROM ledgers WHERE id = $1 AND personal_owner_id IS NOT NULL", [request.params.ledgerId]);
if (personalLedger.rowCount) return reply.code(403).send({ error: "个人账本不能共享" });
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
return reply.code(403).send({ error: "只有账本拥有者可以邀请成员" });
}
@ -432,13 +428,20 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
await client.query("BEGIN");
const result = await client.query<{
id: string; ledgerId: string; role: MemberRole; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null;
personalOwnerId: string | null;
}>(
`SELECT id, ledger_id AS "ledgerId", role, expires_at AS "expiresAt",
accepted_at AS "acceptedAt", revoked_at AS "revokedAt"
FROM ledger_invitations WHERE key_hash = $1 FOR UPDATE`,
`SELECT i.id, i.ledger_id AS "ledgerId", i.role, i.expires_at AS "expiresAt",
i.accepted_at AS "acceptedAt", i.revoked_at AS "revokedAt",
l.personal_owner_id AS "personalOwnerId"
FROM ledger_invitations i JOIN ledgers l ON l.id = i.ledger_id
WHERE i.key_hash = $1 FOR UPDATE OF i`,
[hashSecret(request.params.key)],
);
const invitation = result.rows[0];
if (invitation?.personalOwnerId) {
await client.query("ROLLBACK");
return reply.code(403).send({ error: "个人账本不能共享" });
}
const status = invitation ? invitationStatus(invitation) : "missing";
if (status !== "valid") {
await client.query("ROLLBACK");
@ -526,6 +529,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
return reply.code(403).send({ error: "无权关联其中一个账本" });
}
const entryOwnerId = existing.rows[0]?.ownerId ?? user.id;
const personalLedger = await client.query<{ id: string }>(
"SELECT id FROM ledgers WHERE personal_owner_id = $1",
[entryOwnerId],
);
if (!personalLedger.rows[0]) throw new Error("用户缺少个人账本");
const targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
const entry = operation.payload;
const writeResult = await client.query(
`INSERT INTO entries (
@ -567,7 +578,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
`UPDATE entry_ledgers SET unlinked_at = now(), updated_at = now()
WHERE entry_id = $1 AND unlinked_at IS NULL
AND NOT (ledger_id = ANY($2::text[]))`,
[entry.id, operation.ledgerIds],
[entry.id, targetLedgerIds],
);
} else if (existing.rows[0]) {
await client.query(
@ -576,7 +587,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
WHERE el.entry_id = $1 AND el.unlinked_at IS NULL AND el.ledger_id = m.ledger_id
AND m.user_id = $2 AND m.removed_at IS NULL
AND NOT (el.ledger_id = ANY($3::text[]))`,
[entry.id, user.id, operation.ledgerIds],
[entry.id, user.id, targetLedgerIds],
);
}
await client.query(
@ -584,7 +595,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
SELECT $1, unnest($2::text[])
ON CONFLICT (entry_id, ledger_id) DO UPDATE
SET unlinked_at = NULL, updated_at = now()`,
[entry.id, operation.ledgerIds],
[entry.id, targetLedgerIds],
);
}
await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);