Add mandatory personal ledgers
This commit is contained in:
parent
50dfafc1d2
commit
2db043cda6
|
|
@ -23,7 +23,8 @@ type SelectorLevel = "type" | "category" | "subcategory";
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
ledgerId: string;
|
ledgerId: string;
|
||||||
ledgerName: 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<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -41,7 +42,7 @@ const selectorOpen = ref(false);
|
||||||
const selectorLevel = ref<SelectorLevel>("type");
|
const selectorLevel = ref<SelectorLevel>("type");
|
||||||
const noteEditing = ref(false);
|
const noteEditing = ref(false);
|
||||||
const ledgerPickerOpen = 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);
|
const axisOffset = ref(0);
|
||||||
let axisPointerId: number | null = null;
|
let axisPointerId: number | null = null;
|
||||||
let axisDragStartY = 0;
|
let axisDragStartY = 0;
|
||||||
|
|
@ -67,9 +68,14 @@ const displayAmount = computed(() =>
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(calculatedAmount.value),
|
}).format(calculatedAmount.value),
|
||||||
);
|
);
|
||||||
const selectedLedgerLabel = computed(() => selectedLedgerIds.value.length === 1
|
const selectedLedgerLabel = computed(() => {
|
||||||
? props.ledgerOptions.find((ledger) => ledger.id === selectedLedgerIds.value[0])?.name ?? props.ledgerName
|
const visibleNames = props.ledgerOptions
|
||||||
: `${selectedLedgerIds.value.length} 个账本`);
|
.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 dateLabel = computed(() => {
|
||||||
const date = new Date(occurredAt.value);
|
const date = new Date(occurredAt.value);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -327,6 +333,7 @@ function openLedgerPicker() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLedger(ledgerId: string) {
|
function toggleLedger(ledgerId: string) {
|
||||||
|
if (ledgerId === props.personalLedgerId) return;
|
||||||
if (selectedLedgerIds.value.includes(ledgerId)) {
|
if (selectedLedgerIds.value.includes(ledgerId)) {
|
||||||
if (selectedLedgerIds.value.length > 1) {
|
if (selectedLedgerIds.value.length > 1) {
|
||||||
selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId);
|
selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId);
|
||||||
|
|
@ -525,9 +532,9 @@ function saveEntry() {
|
||||||
<div class="quick-ledger-picker-handle"></div>
|
<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>
|
<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">
|
<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>
|
<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" />
|
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const drawerOpen = ref(false);
|
||||||
const savedToast = ref(false);
|
const savedToast = ref(false);
|
||||||
|
|
||||||
const ledgerOptions = computed(() =>
|
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 () => {
|
onMounted(async () => {
|
||||||
|
|
@ -63,6 +63,7 @@ async function saveEntry(input: {
|
||||||
:ledger-id="ledgerStore.currentLedger.id"
|
:ledger-id="ledgerStore.currentLedger.id"
|
||||||
:ledger-name="ledgerStore.currentLedger.name"
|
:ledger-name="ledgerStore.currentLedger.name"
|
||||||
:ledger-options="ledgerOptions"
|
:ledger-options="ledgerOptions"
|
||||||
|
:personal-ledger-id="ledgerStore.personalLedger?.id ?? ''"
|
||||||
@close="drawerOpen = false"
|
@close="drawerOpen = false"
|
||||||
@save="saveEntry"
|
@save="saveEntry"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type LedgerRecord = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
role?: "owner" | "member";
|
role?: "owner" | "member";
|
||||||
|
isPersonal: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserPreference = {
|
export type UserPreference = {
|
||||||
|
|
@ -34,6 +35,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
isPersonal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "travel-ledger",
|
id: "travel-ledger",
|
||||||
|
|
@ -43,6 +45,7 @@ export function createDefaultLedgers(): LedgerRecord[] {
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
isPersonal: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
currentRole(): "owner" | "member" | null {
|
currentRole(): "owner" | "member" | null {
|
||||||
return this.currentLedger?.role ?? null;
|
return this.currentLedger?.role ?? null;
|
||||||
},
|
},
|
||||||
|
personalLedger(state) {
|
||||||
|
return state.ledgers.find((ledger) => ledger.isPersonal) ?? null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async loadLedgers() {
|
async loadLedgers() {
|
||||||
|
|
|
||||||
|
|
@ -695,7 +695,9 @@ input:focus-visible {
|
||||||
.quick-ledger-options > button:last-child { border-bottom:0; }
|
.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 > 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 { 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 > 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; }
|
.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 {
|
.amount-result {
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ onMounted(async () => {
|
||||||
function hydrate(value: LedgerEntry) {
|
function hydrate(value: LedgerEntry) {
|
||||||
entry.value = value;
|
entry.value = value;
|
||||||
amount.value = (value.amount / 100).toFixed(2);
|
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;
|
note.value = value.note;
|
||||||
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
|
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
|
||||||
const path = findCategoryPath(value.type, value.categoryId);
|
const path = findCategoryPath(value.type, value.categoryId);
|
||||||
|
|
@ -137,6 +137,7 @@ function chooseChild(category: Category) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLedger(ledgerId: string) {
|
function toggleLedger(ledgerId: string) {
|
||||||
|
if (ledgerId === ledgerStore.personalLedger?.id) return;
|
||||||
if (ledgerIds.value.includes(ledgerId)) {
|
if (ledgerIds.value.includes(ledgerId)) {
|
||||||
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
|
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
|
||||||
return;
|
return;
|
||||||
|
|
@ -346,9 +347,9 @@ async function deleteEntry() {
|
||||||
<div class="quick-ledger-picker-handle"></div>
|
<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>
|
<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">
|
<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>
|
<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" />
|
<Check v-if="ledgerIds.includes(ledger.id)" :size="19" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ async function save() {
|
||||||
</header>
|
</header>
|
||||||
<form class="settings-form" @submit.prevent="save">
|
<form class="settings-form" @submit.prevent="save">
|
||||||
<section>
|
<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>
|
<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>
|
||||||
<section class="color-setting">
|
<section class="color-setting">
|
||||||
|
|
|
||||||
|
|
@ -236,11 +236,12 @@ async function shareLedger() {
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<header class="ledger-header">
|
<header class="ledger-header">
|
||||||
<div class="top-actions">
|
<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">
|
<span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">
|
||||||
{{ member.name.slice(0, 1) }}
|
{{ member.name.slice(0, 1) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else aria-hidden="true"></span>
|
||||||
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
|
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
|
||||||
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
|
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
|
||||||
<ChevronDown :size="16" />
|
<ChevronDown :size="16" />
|
||||||
|
|
@ -255,7 +256,7 @@ async function shareLedger() {
|
||||||
<button class="ledger-more-scrim" type="button" aria-label="关闭更多菜单" @click="moreMenuOpen = false"></button>
|
<button class="ledger-more-scrim" type="button" aria-label="关闭更多菜单" @click="moreMenuOpen = false"></button>
|
||||||
<div class="ledger-more-menu" role="menu">
|
<div class="ledger-more-menu" role="menu">
|
||||||
<button type="button" role="menuitem" @click="openLedgerSettings"><Settings :size="18" />设置</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ async function createLedger() {
|
||||||
<section class="ledger-list" aria-label="全部账本">
|
<section class="ledger-list" aria-label="全部账本">
|
||||||
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" @click="selectLedger(ledger.id)">
|
<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 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" />
|
<Check v-if="ledgerStore.currentLedgerId === ledger.id" class="current-ledger-check" :size="20" />
|
||||||
<ChevronRight v-else :size="19" />
|
<ChevronRight v-else :size="19" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,22 @@ PUBLIC_ORIGIN=https://cents.example.com npm run bootstrap --workspace @cents/api
|
||||||
|
|
||||||
应用邀请使用 256 位随机 `key`,数据库只保存 SHA-256 摘要。链接固定 7 天过期并且只能创建一个账号。
|
应用邀请使用 256 位随机 `key`,数据库只保存 SHA-256 摘要。链接固定 7 天过期并且只能创建一个账号。
|
||||||
|
|
||||||
|
管理员也可以直接添加用户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run user:add -- '用户名' '至少8位的密码'
|
||||||
|
```
|
||||||
|
|
||||||
|
用户与个人账本在同一数据库事务内创建;用户名已存在或任一步失败时都不会留下半成品数据。
|
||||||
|
|
||||||
新用户打开 `/invite?key=...` 后填写姓名和密码:
|
新用户打开 `/invite?key=...` 后填写姓名和密码:
|
||||||
|
|
||||||
- 用户 ID 由服务端生成 UUID。
|
- 用户 ID 由服务端生成 UUID。
|
||||||
- 姓名同时作为登录名,忽略首尾空格和大小写后必须唯一。
|
- 姓名同时作为登录名,忽略首尾空格和大小写后必须唯一。
|
||||||
- 密码长度为 8 至 128 个字符。
|
- 密码长度为 8 至 128 个字符。
|
||||||
- 密码使用 Argon2id 保存,参数为 19 MiB 内存、2 次迭代、并行度 1。
|
- 密码使用 Argon2id 保存,参数为 19 MiB 内存、2 次迭代、并行度 1。
|
||||||
- 新账号自动获得一个自己的默认账本,并成为 owner。
|
- 新账号自动获得唯一的“个人账本”,并成为 owner。
|
||||||
|
- 个人账本不能分享或加入其他成员,每笔账目都会由服务端强制关联到其所有者的个人账本。
|
||||||
|
|
||||||
已登录用户打开应用邀请时直接返回应用,不创建账号,也不消费邀请。
|
已登录用户打开应用邀请时直接返回应用,不创建账号,也不消费邀请。
|
||||||
|
|
||||||
|
|
@ -56,6 +65,7 @@ NEW_PASSWORD='新的密码' npm run password:reset --workspace @cents/api -- '
|
||||||
|
|
||||||
- 用户只能访问自己是成员的账本。
|
- 用户只能访问自己是成员的账本。
|
||||||
- `owner` 可以修改账本设置并创建账本邀请。
|
- `owner` 可以修改账本设置并创建账本邀请。
|
||||||
|
- 个人账本名称固定,且不能创建账本邀请。
|
||||||
- `member` 可以查看和记账,但不能创建邀请或修改账本设置。
|
- `member` 可以查看和记账,但不能创建邀请或修改账本设置。
|
||||||
- 服务端在每次请求时查询成员关系,不依赖前端隐藏按钮。
|
- 服务端在每次请求时查询成员关系,不依赖前端隐藏按钮。
|
||||||
- 所有写请求检查 `Origin`,API 同时启用安全响应头和请求限速。
|
- 所有写请求检查 `Origin`,API 同时启用安全响应头和请求限速。
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"dev:web": "npm run dev --workspace @cents/web",
|
"dev:web": "npm run dev --workspace @cents/web",
|
||||||
"dev:web:tunnel": "npm run dev --workspace @cents/web -- --port 6064",
|
"dev:web:tunnel": "npm run dev --workspace @cents/web -- --port 6064",
|
||||||
"dev:api": "npm run dev --workspace @cents/api",
|
"dev:api": "npm run dev --workspace @cents/api",
|
||||||
|
"user:add": "npm run user:add --workspace @cents/api --",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"lint": "npm run lint --workspaces --if-present"
|
"lint": "npm run lint --workspaces --if-present"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"start": "tsx src/server.ts",
|
"start": "tsx src/server.ts",
|
||||||
"bootstrap": "tsx src/bootstrap.ts",
|
"bootstrap": "tsx src/bootstrap.ts",
|
||||||
|
"user:add": "tsx src/add-user.ts",
|
||||||
"password:reset": "tsx src/reset-password.ts",
|
"password:reset": "tsx src/reset-password.ts",
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
import { createId } from "./security.js";
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
|
@ -32,6 +33,10 @@ CREATE TABLE IF NOT EXISTS ledgers (
|
||||||
archived_at timestamptz
|
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 (
|
CREATE TABLE IF NOT EXISTS ledger_members (
|
||||||
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||||
user_id text NOT NULL REFERENCES users(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() {
|
export async function initializeDatabase() {
|
||||||
await pool.query(schema);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import helmet from "@fastify/helmet";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
||||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
|
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
|
||||||
import { initializeDatabase, pool } from "./db.js";
|
import { initializeDatabase, pool } from "./db.js";
|
||||||
import {
|
import {
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
|
@ -232,10 +233,11 @@ server.get("/api/ledgers", async (request, reply) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency",
|
`SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency",
|
||||||
l.created_at AS "createdAt", l.updated_at AS "updatedAt",
|
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
|
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
|
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],
|
[user.id],
|
||||||
);
|
);
|
||||||
return { ledgers: result.rows };
|
return { ledgers: result.rows };
|
||||||
|
|
@ -304,6 +306,10 @@ server.patch<{
|
||||||
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
|
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
|
||||||
return reply.code(403).send({ error: "只有账本拥有者可以修改设置" });
|
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() ?? "";
|
const name = request.body.name?.trim() ?? "";
|
||||||
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" });
|
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" });
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|
@ -311,7 +317,7 @@ server.patch<{
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
RETURNING id, name, color, default_currency AS "defaultCurrency",
|
RETURNING id, name, color, default_currency AS "defaultCurrency",
|
||||||
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
|
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] };
|
return { ledger: result.rows[0] };
|
||||||
});
|
});
|
||||||
|
|
@ -341,8 +347,6 @@ server.post<{ Params: { key: string }; Body: { name?: string; password?: string
|
||||||
const password = request.body.password ?? "";
|
const password = request.body.password ?? "";
|
||||||
if (!name || name.length > 40) return reply.code(400).send({ error: "请填写 1 至 40 个字符的姓名" });
|
if (!name || name.length > 40) return reply.code(400).send({ error: "请填写 1 至 40 个字符的姓名" });
|
||||||
if (!validPassword(password)) return reply.code(400).send({ error: "密码应为 8 至 128 个字符" });
|
if (!validPassword(password)) return reply.code(400).send({ error: "密码应为 8 至 128 个字符" });
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
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" ? "邀请已撤销" : "邀请链接无效" });
|
return reply.code(code).send({ error: status === "accepted" ? "邀请已被使用" : status === "expired" ? "邀请已过期" : status === "revoked" ? "邀请已撤销" : "邀请链接无效" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: User = { id: createId(), name };
|
const user = await createUserWithPersonalLedger(client, name, 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) VALUES ($1, '家庭日常')", [ledgerId]);
|
|
||||||
await client.query(
|
|
||||||
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
|
|
||||||
[ledgerId, user.id],
|
|
||||||
);
|
|
||||||
await client.query(
|
await client.query(
|
||||||
"UPDATE app_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
|
"UPDATE app_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
|
||||||
[user.id, invitation!.id],
|
[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) => {
|
server.post<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/invitations", async (request, reply) => {
|
||||||
const user = await requireUser(request, reply);
|
const user = await requireUser(request, reply);
|
||||||
if (!user) return;
|
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") {
|
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
|
||||||
return reply.code(403).send({ error: "只有账本拥有者可以邀请成员" });
|
return reply.code(403).send({ error: "只有账本拥有者可以邀请成员" });
|
||||||
}
|
}
|
||||||
|
|
@ -432,13 +428,20 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
const result = await client.query<{
|
const result = await client.query<{
|
||||||
id: string; ledgerId: string; role: MemberRole; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null;
|
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",
|
`SELECT i.id, i.ledger_id AS "ledgerId", i.role, i.expires_at AS "expiresAt",
|
||||||
accepted_at AS "acceptedAt", revoked_at AS "revokedAt"
|
i.accepted_at AS "acceptedAt", i.revoked_at AS "revokedAt",
|
||||||
FROM ledger_invitations WHERE key_hash = $1 FOR UPDATE`,
|
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)],
|
[hashSecret(request.params.key)],
|
||||||
);
|
);
|
||||||
const invitation = result.rows[0];
|
const invitation = result.rows[0];
|
||||||
|
if (invitation?.personalOwnerId) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return reply.code(403).send({ error: "个人账本不能共享" });
|
||||||
|
}
|
||||||
const status = invitation ? invitationStatus(invitation) : "missing";
|
const status = invitation ? invitationStatus(invitation) : "missing";
|
||||||
if (status !== "valid") {
|
if (status !== "valid") {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
|
|
@ -526,6 +529,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
||||||
return reply.code(403).send({ error: "无权关联其中一个账本" });
|
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 entry = operation.payload;
|
||||||
const writeResult = await client.query(
|
const writeResult = await client.query(
|
||||||
`INSERT INTO entries (
|
`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()
|
`UPDATE entry_ledgers SET unlinked_at = now(), updated_at = now()
|
||||||
WHERE entry_id = $1 AND unlinked_at IS NULL
|
WHERE entry_id = $1 AND unlinked_at IS NULL
|
||||||
AND NOT (ledger_id = ANY($2::text[]))`,
|
AND NOT (ledger_id = ANY($2::text[]))`,
|
||||||
[entry.id, operation.ledgerIds],
|
[entry.id, targetLedgerIds],
|
||||||
);
|
);
|
||||||
} else if (existing.rows[0]) {
|
} else if (existing.rows[0]) {
|
||||||
await client.query(
|
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
|
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 m.user_id = $2 AND m.removed_at IS NULL
|
||||||
AND NOT (el.ledger_id = ANY($3::text[]))`,
|
AND NOT (el.ledger_id = ANY($3::text[]))`,
|
||||||
[entry.id, user.id, operation.ledgerIds],
|
[entry.id, user.id, targetLedgerIds],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -584,7 +595,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
||||||
SELECT $1, unnest($2::text[])
|
SELECT $1, unnest($2::text[])
|
||||||
ON CONFLICT (entry_id, ledger_id) DO UPDATE
|
ON CONFLICT (entry_id, ledger_id) DO UPDATE
|
||||||
SET unlinked_at = NULL, updated_at = now()`,
|
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]);
|
await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue