libero3 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COLD_START.md +31 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/data/migrations/001_initial_schema.sql +43 -0
- package/data/migrations/002_seed_categories.sql +18 -0
- package/data/migrations/003_add_rating.sql +4 -0
- package/data/migrations/004_timer_sessions.sql +21 -0
- package/data/migrations/005_profile_settings.sql +5 -0
- package/data/migrations/006_add_timeblock_status.sql +1 -0
- package/data/migrations/007_reminders.sql +17 -0
- package/data/migrations/008_timer_target_ms.sql +5 -0
- package/data/migrations/009_tasks.sql +22 -0
- package/data/migrations/010_timer_task_id.sql +4 -0
- package/data/migrations/011_profile_default_confirmed.sql +4 -0
- package/data/migrations/012_identity_tables.sql +68 -0
- package/data/migrations/013_identity_managed_by.sql +3 -0
- package/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/data/migrations/015_timeblock_asked_at.sql +5 -0
- package/data/migrations/016_intent_columns.sql +8 -0
- package/data/migrations/017_ap_wallet.sql +25 -0
- package/data/migrations/018_ap_rewards.sql +21 -0
- package/data/migrations/019_ap_bounty.sql +10 -0
- package/data/migrations/020_chat_turns.sql +10 -0
- package/data/migrations/021_portrait.sql +4 -0
- package/data/migrations/022_family_emoji_single.sql +3 -0
- package/data/migrations/023_ap_balance_view_log.sql +9 -0
- package/data/schema.sql +75 -0
- package/dist/data/db.js +83 -0
- package/dist/data/migrations/001_initial_schema.sql +43 -0
- package/dist/data/migrations/002_seed_categories.sql +18 -0
- package/dist/data/migrations/003_add_rating.sql +4 -0
- package/dist/data/migrations/004_timer_sessions.sql +21 -0
- package/dist/data/migrations/005_profile_settings.sql +5 -0
- package/dist/data/migrations/006_add_timeblock_status.sql +1 -0
- package/dist/data/migrations/007_reminders.sql +17 -0
- package/dist/data/migrations/008_timer_target_ms.sql +5 -0
- package/dist/data/migrations/009_tasks.sql +22 -0
- package/dist/data/migrations/010_timer_task_id.sql +4 -0
- package/dist/data/migrations/011_profile_default_confirmed.sql +4 -0
- package/dist/data/migrations/012_identity_tables.sql +68 -0
- package/dist/data/migrations/013_identity_managed_by.sql +3 -0
- package/dist/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/dist/data/migrations/015_timeblock_asked_at.sql +5 -0
- package/dist/data/migrations/016_intent_columns.sql +8 -0
- package/dist/data/migrations/017_ap_wallet.sql +25 -0
- package/dist/data/migrations/018_ap_rewards.sql +21 -0
- package/dist/data/migrations/019_ap_bounty.sql +10 -0
- package/dist/data/migrations/020_chat_turns.sql +10 -0
- package/dist/data/migrations/021_portrait.sql +4 -0
- package/dist/data/migrations/022_family_emoji_single.sql +3 -0
- package/dist/data/migrations/023_ap_balance_view_log.sql +9 -0
- package/dist/data/schema.sql +75 -0
- package/dist/knowledge/_registry/dimensions.yaml +10 -0
- package/dist/knowledge/_registry/personas.yaml +7 -0
- package/dist/knowledge/_schema/analysis-model.schema.js +32 -0
- package/dist/knowledge/_schema/entry.schema.js +45 -0
- package/dist/knowledge/_schema/persona.schema.js +23 -0
- package/dist/knowledge/_schema/scenario-pack.schema.js +29 -0
- package/dist/knowledge/analysis-models/review-default.yaml +16 -0
- package/dist/knowledge/analysis-models/review-summer-plan.yaml +15 -0
- package/dist/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
- package/dist/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
- package/dist/knowledge/brain-science/brain-env-engineering.yaml +22 -0
- package/dist/knowledge/brain-science/brain-extended-mind.yaml +22 -0
- package/dist/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
- package/dist/knowledge/brain-science/brain-peak-end.yaml +29 -0
- package/dist/knowledge/brain-science/brain-pomodoro.yaml +31 -0
- package/dist/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
- package/dist/knowledge/brain-science/brain-single-variable.yaml +15 -0
- package/dist/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
- package/dist/knowledge/energy/energy-active-recovery.yaml +22 -0
- package/dist/knowledge/energy/energy-deliberate-rest.yaml +22 -0
- package/dist/knowledge/energy/energy-exercise-embedded.yaml +22 -0
- package/dist/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
- package/dist/knowledge/energy/energy-four-energy-radar.yaml +22 -0
- package/dist/knowledge/energy/energy-intensity-recovery.yaml +22 -0
- package/dist/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
- package/dist/knowledge/src/repo/fs-yaml.js +252 -0
- package/dist/knowledge/src/repo/types.js +1 -0
- package/dist/knowledge/time/time-batch-windows.yaml +22 -0
- package/dist/knowledge/time/time-conscious-abandon.yaml +22 -0
- package/dist/knowledge/time/time-deep-work-block.yaml +22 -0
- package/dist/knowledge/time/time-finite-commitment.yaml +22 -0
- package/dist/knowledge/time/time-forced-whitespace.yaml +22 -0
- package/dist/knowledge/time/time-gtd-next-action.yaml +22 -0
- package/dist/knowledge/time/time-three-tier-list.yaml +22 -0
- package/dist/mcp/cold-start.js +43 -0
- package/dist/mcp/contract/v1/rest.js +56 -0
- package/dist/mcp/rest-server.js +52 -0
- package/dist/mcp/server-core.js +1334 -0
- package/dist/mcp/server.js +34 -0
- package/dist/mcp/src/ap/index.js +14 -0
- package/dist/mcp/src/ap/rewards.js +44 -0
- package/dist/mcp/src/categories/index.js +12 -0
- package/dist/mcp/src/chat/from-env.js +111 -0
- package/dist/mcp/src/chat/history.js +35 -0
- package/dist/mcp/src/chat/loop.js +131 -0
- package/dist/mcp/src/chat/portrait.js +133 -0
- package/dist/mcp/src/chat/system-prompt.js +23 -0
- package/dist/mcp/src/chat/tool-names.js +27 -0
- package/dist/mcp/src/chat/tool-schema.js +20 -0
- package/dist/mcp/src/chat/types.js +1 -0
- package/dist/mcp/src/clock/index.js +78 -0
- package/dist/mcp/src/coachmod/index.js +135 -0
- package/dist/mcp/src/host-adapters/hermes-cron.js +185 -0
- package/dist/mcp/src/host-adapters/index.js +61 -0
- package/dist/mcp/src/host-adapters/types.js +7 -0
- package/dist/mcp/src/identity/equivalent-ids.js +41 -0
- package/dist/mcp/src/identity/in-memory.js +100 -0
- package/dist/mcp/src/identity/resolve.js +60 -0
- package/dist/mcp/src/identity/types.js +4 -0
- package/dist/mcp/src/matrix/index.js +15 -0
- package/dist/mcp/src/profile/confirm-default.js +48 -0
- package/dist/mcp/src/profile/index.js +298 -0
- package/dist/mcp/src/profile-confirm.js +28 -0
- package/dist/mcp/src/profile-helpers.js +109 -0
- package/dist/mcp/src/reminder/hermes-channel.js +40 -0
- package/dist/mcp/src/reminder/index.js +70 -0
- package/dist/mcp/src/reminder/push-channel.js +10 -0
- package/dist/mcp/src/repo/factory.js +34 -0
- package/dist/mcp/src/repo/in-memory.js +469 -0
- package/dist/mcp/src/repo/sqlite-ap.js +188 -0
- package/dist/mcp/src/repo/sqlite-helpers.js +39 -0
- package/dist/mcp/src/repo/sqlite-identity.js +129 -0
- package/dist/mcp/src/repo/sqlite.js +754 -0
- package/dist/mcp/src/repo/types.js +4 -0
- package/dist/mcp/src/rest/app.js +121 -0
- package/dist/mcp/src/rest/invoke-tool.js +98 -0
- package/dist/mcp/src/rest/short-token.js +38 -0
- package/dist/mcp/src/rest/tokens.js +55 -0
- package/dist/mcp/src/scheduler/index.js +319 -0
- package/dist/mcp/src/stats/index.js +29 -0
- package/dist/mcp/src/stickiness/report.js +174 -0
- package/dist/mcp/src/stickiness/types.js +1 -0
- package/dist/mcp/src/task/index.js +412 -0
- package/dist/mcp/src/timeblock/index.js +206 -0
- package/dist/mcp/src/timeline/index.js +705 -0
- package/dist/mcp/src/timer/index.js +234 -0
- package/dist/mcp/src/user-id-resolver.js +30 -0
- package/dist/mcp/src/weixin/app.js +97 -0
- package/dist/mcp/src/weixin/custom-send.js +82 -0
- package/dist/mcp/src/weixin/handle-text.js +49 -0
- package/dist/mcp/src/weixin/reminder-loop.js +39 -0
- package/dist/mcp/src/weixin/signature.js +13 -0
- package/dist/mcp/src/weixin/tencent-asr.js +101 -0
- package/dist/mcp/src/weixin/xml.js +37 -0
- package/dist/mcp/weixin-server.js +158 -0
- package/knowledge/_registry/dimensions.yaml +10 -0
- package/knowledge/_registry/personas.yaml +7 -0
- package/knowledge/_schema/analysis-model.schema.ts +36 -0
- package/knowledge/_schema/entry.schema.ts +50 -0
- package/knowledge/_schema/persona.schema.ts +30 -0
- package/knowledge/_schema/scenario-pack.schema.ts +33 -0
- package/knowledge/analysis-models/review-default.yaml +16 -0
- package/knowledge/analysis-models/review-summer-plan.yaml +15 -0
- package/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
- package/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
- package/knowledge/brain-science/brain-env-engineering.yaml +22 -0
- package/knowledge/brain-science/brain-extended-mind.yaml +22 -0
- package/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
- package/knowledge/brain-science/brain-peak-end.yaml +29 -0
- package/knowledge/brain-science/brain-pomodoro.yaml +31 -0
- package/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
- package/knowledge/brain-science/brain-single-variable.yaml +15 -0
- package/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
- package/knowledge/energy/energy-active-recovery.yaml +22 -0
- package/knowledge/energy/energy-deliberate-rest.yaml +22 -0
- package/knowledge/energy/energy-exercise-embedded.yaml +22 -0
- package/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
- package/knowledge/energy/energy-four-energy-radar.yaml +22 -0
- package/knowledge/energy/energy-intensity-recovery.yaml +22 -0
- package/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
- package/knowledge/src/repo/fs-yaml.ts +282 -0
- package/knowledge/src/repo/types.ts +24 -0
- package/knowledge/tests/fixtures/base/_registry/dimensions.yaml +4 -0
- package/knowledge/tests/fixtures/base/time/valid-entry.yaml +24 -0
- package/knowledge/tests/fixtures/dup/_registry/dimensions.yaml +4 -0
- package/knowledge/tests/fixtures/dup/time/entry-a.yaml +13 -0
- package/knowledge/tests/fixtures/dup/time/entry-b.yaml +13 -0
- package/knowledge/tests/fixtures/unregistered/_registry/dimensions.yaml +7 -0
- package/knowledge/tests/fixtures/unregistered/time/anxiety-entry.yaml +13 -0
- package/knowledge/tests/repo/fs-yaml-knowledge-repo.test.ts +333 -0
- package/knowledge/time/time-batch-windows.yaml +22 -0
- package/knowledge/time/time-conscious-abandon.yaml +22 -0
- package/knowledge/time/time-deep-work-block.yaml +22 -0
- package/knowledge/time/time-finite-commitment.yaml +22 -0
- package/knowledge/time/time-forced-whitespace.yaml +22 -0
- package/knowledge/time/time-gtd-next-action.yaml +22 -0
- package/knowledge/time/time-three-tier-list.yaml +22 -0
- package/knowledge/validate.ts +50 -0
- package/package.json +76 -0
- package/scripts/postinstall-cold-start.js +45 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { BOUNTY_QUESTS, REWARD_CONFIG, dayKey, periodKeyFor, rewardForNth } from "../ap/rewards.js";
|
|
2
|
+
import { buildTimelineFromEvents, computeDefaultWindow, } from "../timeline/index.js";
|
|
3
|
+
import { effectiveUserId } from "../profile-helpers.js";
|
|
4
|
+
/** In-memory CategoryRepo for testing / contract verification.
|
|
5
|
+
* Receives seed data via constructor — no global state, no getDb(). */
|
|
6
|
+
export class InMemoryCategoryRepo {
|
|
7
|
+
categories;
|
|
8
|
+
constructor(seed) {
|
|
9
|
+
this.categories = [...seed];
|
|
10
|
+
}
|
|
11
|
+
async getAll() {
|
|
12
|
+
// Return defensive copy so callers can't mutate internal state
|
|
13
|
+
return [...this.categories];
|
|
14
|
+
}
|
|
15
|
+
async getById(id) {
|
|
16
|
+
return this.categories.find((c) => c.id === id) ?? null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** In-memory TimeBlockRepo for testing / contract verification. */
|
|
20
|
+
export class InMemoryTimeBlockRepo {
|
|
21
|
+
blocks = [];
|
|
22
|
+
userIds;
|
|
23
|
+
constructor(userIds) {
|
|
24
|
+
this.userIds = new Set(userIds);
|
|
25
|
+
}
|
|
26
|
+
async create(input) {
|
|
27
|
+
const now = new Date().toISOString();
|
|
28
|
+
const durationMinutes = input.duration_minutes ??
|
|
29
|
+
Math.round((new Date(input.end_time).getTime() -
|
|
30
|
+
new Date(input.start_time).getTime()) /
|
|
31
|
+
60000);
|
|
32
|
+
const block = {
|
|
33
|
+
id: crypto.randomUUID(),
|
|
34
|
+
user_id: input.user_id,
|
|
35
|
+
title: input.title.trim(),
|
|
36
|
+
category_id: input.category_id ?? null,
|
|
37
|
+
start_time: input.start_time,
|
|
38
|
+
end_time: input.end_time,
|
|
39
|
+
duration_minutes: durationMinutes,
|
|
40
|
+
rating: input.rating ?? null,
|
|
41
|
+
rating_reason: input.rating_reason ?? null,
|
|
42
|
+
notes: input.notes ?? null,
|
|
43
|
+
status: input.status ?? "completed",
|
|
44
|
+
created_at: now,
|
|
45
|
+
updated_at: now,
|
|
46
|
+
deleted_at: null,
|
|
47
|
+
};
|
|
48
|
+
this.blocks.push(block);
|
|
49
|
+
return { ...block };
|
|
50
|
+
}
|
|
51
|
+
async getById(id) {
|
|
52
|
+
const b = this.blocks.find((b) => b.id === id && b.deleted_at === null);
|
|
53
|
+
return b ? { ...b } : null;
|
|
54
|
+
}
|
|
55
|
+
async list(filters) {
|
|
56
|
+
let results = this.blocks.filter((b) => b.user_id === filters.user_id);
|
|
57
|
+
if (!filters.include_deleted) {
|
|
58
|
+
results = results.filter((b) => b.deleted_at === null);
|
|
59
|
+
}
|
|
60
|
+
if (filters.status) {
|
|
61
|
+
results = results.filter((b) => b.status === filters.status);
|
|
62
|
+
}
|
|
63
|
+
if (filters.category_id) {
|
|
64
|
+
results = results.filter((b) => b.category_id === filters.category_id);
|
|
65
|
+
}
|
|
66
|
+
if (filters.date) {
|
|
67
|
+
const dayStart = `${filters.date}T00:00:00`;
|
|
68
|
+
const dayEnd = `${filters.date}T23:59:59`;
|
|
69
|
+
results = results.filter((b) => b.start_time >= dayStart && b.start_time < dayEnd);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
if (filters.from) {
|
|
73
|
+
results = results.filter((b) => b.start_time >= filters.from);
|
|
74
|
+
}
|
|
75
|
+
if (filters.to) {
|
|
76
|
+
results = results.filter((b) => b.start_time <= filters.to);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
results.sort((a, b) => b.start_time.localeCompare(a.start_time));
|
|
80
|
+
const offset = filters.offset ?? 0;
|
|
81
|
+
let limit = filters.limit ?? 50;
|
|
82
|
+
if (limit > 100)
|
|
83
|
+
limit = 100;
|
|
84
|
+
return results.slice(offset, offset + limit).map((b) => ({ ...b }));
|
|
85
|
+
}
|
|
86
|
+
async update(id, input) {
|
|
87
|
+
const b = this.blocks.find((b) => b.id === id && b.deleted_at === null);
|
|
88
|
+
if (!b) {
|
|
89
|
+
throw new Error("时间块不存在");
|
|
90
|
+
}
|
|
91
|
+
const newStart = input.start_time ?? b.start_time;
|
|
92
|
+
const newEnd = input.end_time ?? b.end_time;
|
|
93
|
+
if (input.title !== undefined)
|
|
94
|
+
b.title = input.title.trim() || b.title;
|
|
95
|
+
if (input.category_id !== undefined)
|
|
96
|
+
b.category_id = input.category_id;
|
|
97
|
+
b.start_time = newStart;
|
|
98
|
+
b.end_time = newEnd;
|
|
99
|
+
if (input.duration_minutes !== undefined) {
|
|
100
|
+
b.duration_minutes = input.duration_minutes;
|
|
101
|
+
}
|
|
102
|
+
else if (input.start_time !== undefined || input.end_time !== undefined) {
|
|
103
|
+
b.duration_minutes = Math.round((new Date(newEnd).getTime() - new Date(newStart).getTime()) / 60000);
|
|
104
|
+
}
|
|
105
|
+
if (input.rating !== undefined)
|
|
106
|
+
b.rating = input.rating;
|
|
107
|
+
if (input.rating_reason !== undefined)
|
|
108
|
+
b.rating_reason = input.rating_reason;
|
|
109
|
+
if (input.notes !== undefined)
|
|
110
|
+
b.notes = input.notes;
|
|
111
|
+
if (input.status !== undefined)
|
|
112
|
+
b.status = input.status;
|
|
113
|
+
b.updated_at = new Date().toISOString();
|
|
114
|
+
return { ...b };
|
|
115
|
+
}
|
|
116
|
+
async remove(id) {
|
|
117
|
+
const b = this.blocks.find((b) => b.id === id);
|
|
118
|
+
if (!b) {
|
|
119
|
+
throw new Error("时间块不存在");
|
|
120
|
+
}
|
|
121
|
+
if (b.deleted_at !== null)
|
|
122
|
+
return; // idempotent
|
|
123
|
+
b.deleted_at = new Date().toISOString();
|
|
124
|
+
b.updated_at = b.deleted_at;
|
|
125
|
+
}
|
|
126
|
+
async restore(id) {
|
|
127
|
+
const b = this.blocks.find((b) => b.id === id);
|
|
128
|
+
if (!b || b.deleted_at === null) {
|
|
129
|
+
throw new Error("时间块不存在或未被删除");
|
|
130
|
+
}
|
|
131
|
+
b.deleted_at = null;
|
|
132
|
+
// MV-COACH-3 Slice 4: restore 后 = unverified(对齐 sqlite 实现,等用户核实才升 completed)
|
|
133
|
+
b.status = "unverified";
|
|
134
|
+
b.updated_at = new Date().toISOString();
|
|
135
|
+
return { ...b };
|
|
136
|
+
}
|
|
137
|
+
async exists(id) {
|
|
138
|
+
return this.blocks.some((b) => b.id === id);
|
|
139
|
+
}
|
|
140
|
+
async userExists(userId) {
|
|
141
|
+
return this.userIds.has(userId);
|
|
142
|
+
}
|
|
143
|
+
/** S48(2026-08-11):标记扫描追问已发。幂等保护。 */
|
|
144
|
+
async markAsked(ids, askedAt) {
|
|
145
|
+
if (!ids || ids.length === 0)
|
|
146
|
+
return 0;
|
|
147
|
+
const ts = askedAt ?? new Date().toISOString();
|
|
148
|
+
let changed = 0;
|
|
149
|
+
for (const b of this.blocks) {
|
|
150
|
+
if (ids.includes(b.id) && !b.deleted_at) {
|
|
151
|
+
b.asked_at = ts;
|
|
152
|
+
b.updated_at = ts;
|
|
153
|
+
changed++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return changed;
|
|
157
|
+
}
|
|
158
|
+
async findOverlapping(input) {
|
|
159
|
+
return this.blocks
|
|
160
|
+
.filter((b) => b.user_id === input.user_id &&
|
|
161
|
+
b.status === "planned" &&
|
|
162
|
+
b.deleted_at === null &&
|
|
163
|
+
b.start_time < input.end &&
|
|
164
|
+
b.end_time > input.start)
|
|
165
|
+
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
|
166
|
+
.map((b) => ({ ...b }));
|
|
167
|
+
}
|
|
168
|
+
async findExactWindow(input) {
|
|
169
|
+
const title = input.title.trim();
|
|
170
|
+
const startMs = new Date(input.start).getTime();
|
|
171
|
+
const endMs = new Date(input.end).getTime();
|
|
172
|
+
const b = this.blocks.find((row) => row.user_id === input.user_id &&
|
|
173
|
+
row.deleted_at === null &&
|
|
174
|
+
row.title === title &&
|
|
175
|
+
row.status === input.status &&
|
|
176
|
+
new Date(row.start_time).getTime() === startMs &&
|
|
177
|
+
new Date(row.end_time).getTime() === endMs);
|
|
178
|
+
return b ? { ...b } : null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** In-memory ReminderRepo for testing / tool-layer verification. */
|
|
182
|
+
export class InMemoryReminderRepo {
|
|
183
|
+
reminders = [];
|
|
184
|
+
userIds;
|
|
185
|
+
constructor(userIds) {
|
|
186
|
+
this.userIds = new Set(userIds);
|
|
187
|
+
}
|
|
188
|
+
async create(input) {
|
|
189
|
+
const now = new Date().toISOString();
|
|
190
|
+
// Normalize to UTC Z format — ensures getDueReminders string comparison
|
|
191
|
+
// works correctly even when caller passes +08:00 or other offsets.
|
|
192
|
+
const remindAt = new Date(input.remind_at).toISOString();
|
|
193
|
+
const reminder = {
|
|
194
|
+
id: crypto.randomUUID(),
|
|
195
|
+
user_id: input.user_id,
|
|
196
|
+
timeblock_id: input.timeblock_id ?? null,
|
|
197
|
+
content: input.content,
|
|
198
|
+
remind_at: remindAt,
|
|
199
|
+
channel: input.channel ?? "session",
|
|
200
|
+
status: "pending",
|
|
201
|
+
attempts: 0,
|
|
202
|
+
sent_at: null,
|
|
203
|
+
created_at: now,
|
|
204
|
+
updated_at: now,
|
|
205
|
+
deleted_at: null,
|
|
206
|
+
};
|
|
207
|
+
this.reminders.push(reminder);
|
|
208
|
+
return { ...reminder };
|
|
209
|
+
}
|
|
210
|
+
async getById(id) {
|
|
211
|
+
const r = this.reminders.find((r) => r.id === id && r.deleted_at === null);
|
|
212
|
+
return r ? { ...r } : null;
|
|
213
|
+
}
|
|
214
|
+
async getDueReminders(input) {
|
|
215
|
+
return this.reminders
|
|
216
|
+
.filter((r) => r.status === "pending" &&
|
|
217
|
+
r.remind_at <= input.until &&
|
|
218
|
+
r.deleted_at === null &&
|
|
219
|
+
(input.user_id === undefined || r.user_id === input.user_id))
|
|
220
|
+
.sort((a, b) => a.remind_at.localeCompare(b.remind_at))
|
|
221
|
+
.map((r) => ({ ...r }));
|
|
222
|
+
}
|
|
223
|
+
async getNextDue(input = {}) {
|
|
224
|
+
const pending = this.reminders
|
|
225
|
+
.filter((r) => r.status === "pending" &&
|
|
226
|
+
r.deleted_at === null &&
|
|
227
|
+
(input.user_id === undefined || r.user_id === input.user_id))
|
|
228
|
+
.sort((a, b) => a.remind_at.localeCompare(b.remind_at));
|
|
229
|
+
return pending.length > 0 ? { remind_at: pending[0].remind_at } : null;
|
|
230
|
+
}
|
|
231
|
+
async getMissedReminders(input) {
|
|
232
|
+
return this.reminders
|
|
233
|
+
.filter((r) => r.status === "pending" &&
|
|
234
|
+
r.remind_at < input.now &&
|
|
235
|
+
r.user_id === input.user_id &&
|
|
236
|
+
r.deleted_at === null)
|
|
237
|
+
.sort((a, b) => a.remind_at.localeCompare(b.remind_at))
|
|
238
|
+
.map((r) => ({ ...r }));
|
|
239
|
+
}
|
|
240
|
+
async markSent(id) {
|
|
241
|
+
const r = this.reminders.find((r) => r.id === id && r.deleted_at === null);
|
|
242
|
+
if (!r)
|
|
243
|
+
return; // idempotent: no-op on missing
|
|
244
|
+
if (r.status === "sent")
|
|
245
|
+
return; // idempotent
|
|
246
|
+
const now = new Date().toISOString();
|
|
247
|
+
r.status = "sent";
|
|
248
|
+
r.sent_at = now;
|
|
249
|
+
r.updated_at = now;
|
|
250
|
+
}
|
|
251
|
+
async cancelReminder(id) {
|
|
252
|
+
const r = this.reminders.find((r) => r.id === id && r.deleted_at === null);
|
|
253
|
+
if (!r)
|
|
254
|
+
return; // idempotent
|
|
255
|
+
const now = new Date().toISOString();
|
|
256
|
+
r.status = "cancelled";
|
|
257
|
+
r.updated_at = now;
|
|
258
|
+
}
|
|
259
|
+
async userExists(userId) {
|
|
260
|
+
return this.userIds.has(userId);
|
|
261
|
+
}
|
|
262
|
+
async claim(id) {
|
|
263
|
+
const r = this.reminders.find((r) => r.id === id && r.deleted_at === null);
|
|
264
|
+
if (!r || r.status !== "pending")
|
|
265
|
+
return false;
|
|
266
|
+
r.status = "sending";
|
|
267
|
+
r.updated_at = new Date().toISOString();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
async releaseOrFail(id, maxAttempts) {
|
|
271
|
+
const r = this.reminders.find((r) => r.id === id && r.deleted_at === null);
|
|
272
|
+
if (!r)
|
|
273
|
+
return;
|
|
274
|
+
r.attempts += 1;
|
|
275
|
+
r.updated_at = new Date().toISOString();
|
|
276
|
+
if (r.attempts < maxAttempts) {
|
|
277
|
+
r.status = "pending"; // retry
|
|
278
|
+
}
|
|
279
|
+
// else: stays 'sending' — dead, won't be picked up again
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** In-memory TimelineRepo — independent of other in-memory repos. */
|
|
283
|
+
export class InMemoryTimelineRepo {
|
|
284
|
+
seed;
|
|
285
|
+
constructor(seed) {
|
|
286
|
+
this.seed = {
|
|
287
|
+
timeblocks: [...seed.timeblocks],
|
|
288
|
+
timer_sessions: [...seed.timer_sessions],
|
|
289
|
+
categories: [...seed.categories],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
async renderTimeline(filters) {
|
|
293
|
+
const userId = effectiveUserId(filters);
|
|
294
|
+
const now = new Date();
|
|
295
|
+
let from;
|
|
296
|
+
let to;
|
|
297
|
+
if (filters.from !== undefined || filters.to !== undefined) {
|
|
298
|
+
const def = computeDefaultWindow(now);
|
|
299
|
+
from = filters.from !== undefined ? new Date(filters.from) : def.from;
|
|
300
|
+
to = filters.to !== undefined ? new Date(filters.to) : def.to;
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const def = computeDefaultWindow(now);
|
|
304
|
+
from = def.from;
|
|
305
|
+
to = def.to;
|
|
306
|
+
}
|
|
307
|
+
const fetchFrom = new Date(from);
|
|
308
|
+
fetchFrom.setHours(0, 0, 0, 0);
|
|
309
|
+
const fetchTo = new Date(to);
|
|
310
|
+
fetchTo.setHours(23, 59, 59, 999);
|
|
311
|
+
const fetchFromMs = fetchFrom.getTime();
|
|
312
|
+
const fetchToMs = fetchTo.getTime();
|
|
313
|
+
const catMap = new Map(this.seed.categories.map((c) => [c.id, c]));
|
|
314
|
+
const blocks = this.seed.timeblocks
|
|
315
|
+
.filter((b) => b.user_id === userId &&
|
|
316
|
+
!b.deleted_at &&
|
|
317
|
+
(b.status === "planned" ||
|
|
318
|
+
b.status === "unverified" ||
|
|
319
|
+
b.status === "completed") &&
|
|
320
|
+
new Date(b.start_time).getTime() < fetchToMs &&
|
|
321
|
+
new Date(b.end_time).getTime() > fetchFromMs)
|
|
322
|
+
.map((b) => {
|
|
323
|
+
const cat = b.category_id ? catMap.get(b.category_id) : undefined;
|
|
324
|
+
return {
|
|
325
|
+
start: b.start_time,
|
|
326
|
+
end: b.end_time,
|
|
327
|
+
status: b.status,
|
|
328
|
+
title: b.title,
|
|
329
|
+
category_id: b.category_id ?? null,
|
|
330
|
+
category_emoji: cat?.emoji ?? null,
|
|
331
|
+
category_name: cat?.name ?? null,
|
|
332
|
+
category_color: cat?.color ?? null,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
const timers = this.seed.timer_sessions
|
|
336
|
+
.filter((t) => t.user_id === userId &&
|
|
337
|
+
(t.status === "running" || t.status === "paused"))
|
|
338
|
+
.map((t) => ({
|
|
339
|
+
started_at: t.started_at,
|
|
340
|
+
accumulated_ms: t.accumulated_ms,
|
|
341
|
+
status: t.status,
|
|
342
|
+
event: t.event,
|
|
343
|
+
}));
|
|
344
|
+
return buildTimelineFromEvents({
|
|
345
|
+
blocks,
|
|
346
|
+
timers,
|
|
347
|
+
from,
|
|
348
|
+
to,
|
|
349
|
+
now,
|
|
350
|
+
format: filters.format ?? "emoji",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** In-memory ApRepo for contract tests. */
|
|
355
|
+
export class InMemoryApRepo {
|
|
356
|
+
wallets = new Map();
|
|
357
|
+
creditLog = new Map();
|
|
358
|
+
spendLog = new Map();
|
|
359
|
+
rewardEvents = [];
|
|
360
|
+
rewardCounters = new Map(); // `${user_id}:${action_type}:${period_key}` → count
|
|
361
|
+
bountyClaims = new Set(); // `${user_id}:${date_key}:${quest_id}`
|
|
362
|
+
balanceViews = []; // S3 看余额埋点
|
|
363
|
+
async balance(userId) {
|
|
364
|
+
const bal = this.wallets.get(userId) ?? 0;
|
|
365
|
+
// S3(INT-mvp-100):与 SqliteApRepo 同行为——每次读取追加一条快照。
|
|
366
|
+
this.balanceViews.push({ user_id: userId, balance: bal, created_at: new Date().toISOString() });
|
|
367
|
+
return bal;
|
|
368
|
+
}
|
|
369
|
+
async recordRewardEvent(input) {
|
|
370
|
+
const now = input.now ?? new Date();
|
|
371
|
+
if (this.rewardEvents.some((e) => e.user === input.user_id && e.id === input.event_id))
|
|
372
|
+
return null;
|
|
373
|
+
const pk = periodKeyFor(input.action_type, now);
|
|
374
|
+
const counterKey = `${input.user_id}:${input.action_type}:${pk}`;
|
|
375
|
+
const nth = (this.rewardCounters.get(counterKey) ?? 0) + 1;
|
|
376
|
+
const amount = rewardForNth(input.action_type, nth);
|
|
377
|
+
this.rewardCounters.set(counterKey, nth);
|
|
378
|
+
this.rewardEvents.push({ user: input.user_id, type: input.action_type, id: input.event_id, at: now });
|
|
379
|
+
if (amount > 0) {
|
|
380
|
+
this.wallets.set(input.user_id, (this.wallets.get(input.user_id) ?? 0) + amount);
|
|
381
|
+
}
|
|
382
|
+
// 悬赏奖金:当天该类型事件数达标且未领过
|
|
383
|
+
const dk = dayKey(now);
|
|
384
|
+
let bonus;
|
|
385
|
+
for (const q of BOUNTY_QUESTS) {
|
|
386
|
+
if (q.action_type !== input.action_type)
|
|
387
|
+
continue;
|
|
388
|
+
const claimKey = `${input.user_id}:${dk}:${q.id}`;
|
|
389
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
390
|
+
const end = start + 86400000;
|
|
391
|
+
const dayCount = this.rewardEvents.filter((e) => e.user === input.user_id && e.type === q.action_type && e.at.getTime() >= start && e.at.getTime() < end).length;
|
|
392
|
+
if (dayCount >= q.target && !this.bountyClaims.has(claimKey)) {
|
|
393
|
+
this.bountyClaims.add(claimKey);
|
|
394
|
+
this.wallets.set(input.user_id, (this.wallets.get(input.user_id) ?? 0) + q.bonus);
|
|
395
|
+
bonus = (bonus ?? 0) + q.bonus;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return bonus === undefined ? { nth, amount } : { nth, amount, bonus };
|
|
399
|
+
}
|
|
400
|
+
async rewardStatus(userId, now) {
|
|
401
|
+
const at = now ?? new Date();
|
|
402
|
+
const out = {};
|
|
403
|
+
for (const type of Object.keys(REWARD_CONFIG)) {
|
|
404
|
+
const count = this.rewardCounters.get(`${userId}:${type}:${periodKeyFor(type, at)}`) ?? 0;
|
|
405
|
+
out[type] = { count, next: rewardForNth(type, count + 1), period: REWARD_CONFIG[type].period };
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
async bountiesStatus(userId, now) {
|
|
410
|
+
const at = now ?? new Date();
|
|
411
|
+
const dk = dayKey(at);
|
|
412
|
+
const start = new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime();
|
|
413
|
+
const end = start + 86400000;
|
|
414
|
+
return {
|
|
415
|
+
date: dk,
|
|
416
|
+
quests: BOUNTY_QUESTS.map((q) => {
|
|
417
|
+
const count = Math.min(this.rewardEvents.filter((e) => e.user === userId && e.type === q.action_type && e.at.getTime() >= start && e.at.getTime() < end).length, q.target);
|
|
418
|
+
return {
|
|
419
|
+
id: q.id,
|
|
420
|
+
action_type: q.action_type,
|
|
421
|
+
target: q.target,
|
|
422
|
+
count,
|
|
423
|
+
done: this.bountyClaims.has(`${userId}:${dk}:${q.id}`),
|
|
424
|
+
bonus: q.bonus,
|
|
425
|
+
};
|
|
426
|
+
}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async credit(input) {
|
|
430
|
+
if (input.idempotency_key) {
|
|
431
|
+
const key = `${input.user_id}:${input.idempotency_key}`;
|
|
432
|
+
const existing = this.creditLog.get(key);
|
|
433
|
+
if (existing) {
|
|
434
|
+
return {
|
|
435
|
+
balance: this.wallets.get(input.user_id) ?? 0,
|
|
436
|
+
credited: existing.amount,
|
|
437
|
+
source: existing.source,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
this.creditLog.set(key, { amount: input.amount, source: input.source });
|
|
441
|
+
}
|
|
442
|
+
const next = (this.wallets.get(input.user_id) ?? 0) + input.amount;
|
|
443
|
+
this.wallets.set(input.user_id, next);
|
|
444
|
+
return { balance: next, credited: input.amount, source: input.source };
|
|
445
|
+
}
|
|
446
|
+
async spend(input) {
|
|
447
|
+
if (input.idempotency_key) {
|
|
448
|
+
const key = `${input.user_id}:${input.idempotency_key}`;
|
|
449
|
+
const existing = this.spendLog.get(key);
|
|
450
|
+
if (existing !== undefined) {
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
balance: this.wallets.get(input.user_id) ?? 0,
|
|
454
|
+
spent: existing,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const current = this.wallets.get(input.user_id) ?? 0;
|
|
459
|
+
if (current < input.amount) {
|
|
460
|
+
return { ok: false, code: "insufficient", balance: current };
|
|
461
|
+
}
|
|
462
|
+
const next = current - input.amount;
|
|
463
|
+
this.wallets.set(input.user_id, next);
|
|
464
|
+
if (input.idempotency_key) {
|
|
465
|
+
this.spendLog.set(`${input.user_id}:${input.idempotency_key}`, input.amount);
|
|
466
|
+
}
|
|
467
|
+
return { ok: true, balance: next, spent: input.amount };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { BOUNTY_QUESTS, REWARD_CONFIG, dayKey, periodKeyFor, rewardForNth } from "../ap/rewards.js";
|
|
2
|
+
/**
|
|
3
|
+
* SqliteApRepo — AP 账真源(INT-AP-tools R2)。
|
|
4
|
+
* credit / spend 包在 transaction 里,幂等靠 UNIQUE(user_id, idempotency_key)。
|
|
5
|
+
*/
|
|
6
|
+
export class SqliteApRepo {
|
|
7
|
+
db;
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
}
|
|
11
|
+
/** INT-ap-rewards:事件幂等 + 曲线计数 + 入账 + 悬赏奖金,单事务(计数与余额同进退) */
|
|
12
|
+
async recordRewardEvent(input) {
|
|
13
|
+
const now = input.now ?? new Date();
|
|
14
|
+
const nowISO = now.toISOString();
|
|
15
|
+
return this.db.transaction(() => {
|
|
16
|
+
const seen = this.db
|
|
17
|
+
.prepare("SELECT 1 FROM ap_reward_log WHERE user_id = ? AND event_id = ?")
|
|
18
|
+
.get(input.user_id, input.event_id);
|
|
19
|
+
if (seen)
|
|
20
|
+
return null; // 重放:不计数不入账
|
|
21
|
+
const pk = periodKeyFor(input.action_type, now);
|
|
22
|
+
const row = this.db
|
|
23
|
+
.prepare("SELECT count FROM ap_reward_counter WHERE user_id = ? AND action_type = ? AND period_key = ?")
|
|
24
|
+
.get(input.user_id, input.action_type, pk);
|
|
25
|
+
const nth = (row?.count ?? 0) + 1;
|
|
26
|
+
const amount = rewardForNth(input.action_type, nth);
|
|
27
|
+
this.db
|
|
28
|
+
.prepare(`INSERT INTO ap_reward_counter (user_id, action_type, period_key, count, updated_at)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?)
|
|
30
|
+
ON CONFLICT(user_id, action_type, period_key) DO UPDATE SET count = excluded.count, updated_at = excluded.updated_at`)
|
|
31
|
+
.run(input.user_id, input.action_type, pk, nth, nowISO);
|
|
32
|
+
this.db
|
|
33
|
+
.prepare("INSERT INTO ap_reward_log (user_id, event_id, action_type, period_key, nth, amount, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
34
|
+
.run(input.user_id, input.event_id, input.action_type, pk, nth, amount, nowISO);
|
|
35
|
+
if (amount > 0) {
|
|
36
|
+
// 入账与计数同事务;幂等键兜底跨面重放
|
|
37
|
+
this.db
|
|
38
|
+
.prepare("INSERT INTO ap_credit_log (user_id, idempotency_key, amount, source, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
39
|
+
.run(input.user_id, `reward:${input.event_id}`, amount, `libero:${input.action_type}`, nowISO);
|
|
40
|
+
this.addBalance(input.user_id, amount);
|
|
41
|
+
}
|
|
42
|
+
// 悬赏奖金(方案 A):当天该类型事件数达标且未领过 → 同事务发放
|
|
43
|
+
const dk = dayKey(now);
|
|
44
|
+
let bonus;
|
|
45
|
+
for (const q of BOUNTY_QUESTS) {
|
|
46
|
+
if (q.action_type !== input.action_type)
|
|
47
|
+
continue;
|
|
48
|
+
const dayCount = this.dayCountSync(input.user_id, q.action_type, now);
|
|
49
|
+
const claimed = this.db
|
|
50
|
+
.prepare("SELECT 1 FROM ap_bounty_log WHERE user_id = ? AND date_key = ? AND quest_id = ?")
|
|
51
|
+
.get(input.user_id, dk, q.id);
|
|
52
|
+
if (dayCount >= q.target && !claimed) {
|
|
53
|
+
this.db
|
|
54
|
+
.prepare("INSERT INTO ap_bounty_log (user_id, date_key, quest_id, bonus, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
55
|
+
.run(input.user_id, dk, q.id, q.bonus, nowISO);
|
|
56
|
+
this.db
|
|
57
|
+
.prepare("INSERT INTO ap_credit_log (user_id, idempotency_key, amount, source, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
58
|
+
.run(input.user_id, `bounty:${dk}:${q.id}`, q.bonus, `bounty:${q.id}`, nowISO);
|
|
59
|
+
this.addBalance(input.user_id, q.bonus);
|
|
60
|
+
bonus = (bonus ?? 0) + q.bonus;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return bonus === undefined ? { nth, amount } : { nth, amount, bonus };
|
|
64
|
+
})();
|
|
65
|
+
}
|
|
66
|
+
/** 当天(本地时区)该类型实绩事件数——悬赏进度与曲线周期无关,按天判 */
|
|
67
|
+
dayCountSync(userId, actionType, now) {
|
|
68
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
69
|
+
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
70
|
+
const row = this.db
|
|
71
|
+
.prepare("SELECT COUNT(*) AS c FROM ap_reward_log WHERE user_id = ? AND action_type = ? AND created_at >= ? AND created_at < ?")
|
|
72
|
+
.get(userId, actionType, start.toISOString(), end.toISOString());
|
|
73
|
+
return row.c;
|
|
74
|
+
}
|
|
75
|
+
async bountiesStatus(userId, now) {
|
|
76
|
+
const at = now ?? new Date();
|
|
77
|
+
const dk = dayKey(at);
|
|
78
|
+
return {
|
|
79
|
+
date: dk,
|
|
80
|
+
quests: BOUNTY_QUESTS.map((q) => {
|
|
81
|
+
const count = this.dayCountSync(userId, q.action_type, at);
|
|
82
|
+
const done = count >= q.target &&
|
|
83
|
+
!!this.db
|
|
84
|
+
.prepare("SELECT 1 FROM ap_bounty_log WHERE user_id = ? AND date_key = ? AND quest_id = ?")
|
|
85
|
+
.get(userId, dk, q.id);
|
|
86
|
+
return { id: q.id, action_type: q.action_type, target: q.target, count: Math.min(count, q.target), done, bonus: q.bonus };
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async rewardStatus(userId, now) {
|
|
91
|
+
const at = now ?? new Date();
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const type of Object.keys(REWARD_CONFIG)) {
|
|
94
|
+
const row = this.db
|
|
95
|
+
.prepare("SELECT count FROM ap_reward_counter WHERE user_id = ? AND action_type = ? AND period_key = ?")
|
|
96
|
+
.get(userId, type, periodKeyFor(type, at));
|
|
97
|
+
const count = row?.count ?? 0;
|
|
98
|
+
out[type] = { count, next: rewardForNth(type, count + 1), period: REWARD_CONFIG[type].period };
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
async balance(userId) {
|
|
103
|
+
const bal = this.balanceSync(userId);
|
|
104
|
+
// S3(INT-mvp-100):看余额埋点。只追加,只记显式 balance 调用——
|
|
105
|
+
// credit/spend 内部经 balanceSync 取返回值不算「看」,故埋点放这里而非 balanceSync。
|
|
106
|
+
this.db
|
|
107
|
+
.prepare("INSERT INTO ap_balance_view_log (user_id, balance, created_at) VALUES (?, ?, ?)")
|
|
108
|
+
.run(userId, bal, new Date().toISOString());
|
|
109
|
+
return bal;
|
|
110
|
+
}
|
|
111
|
+
async credit(input) {
|
|
112
|
+
return this.db.transaction(() => {
|
|
113
|
+
if (input.idempotency_key) {
|
|
114
|
+
const existing = this.db
|
|
115
|
+
.prepare("SELECT amount, source FROM ap_credit_log WHERE user_id = ? AND idempotency_key = ?")
|
|
116
|
+
.get(input.user_id, input.idempotency_key);
|
|
117
|
+
if (existing) {
|
|
118
|
+
return {
|
|
119
|
+
balance: this.balanceSync(input.user_id),
|
|
120
|
+
credited: existing.amount,
|
|
121
|
+
source: existing.source,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
this.db
|
|
126
|
+
.prepare("INSERT INTO ap_credit_log (user_id, idempotency_key, amount, source, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
127
|
+
.run(input.user_id, input.idempotency_key, input.amount, input.source, now);
|
|
128
|
+
}
|
|
129
|
+
this.addBalance(input.user_id, input.amount);
|
|
130
|
+
return {
|
|
131
|
+
balance: this.balanceSync(input.user_id),
|
|
132
|
+
credited: input.amount,
|
|
133
|
+
source: input.source,
|
|
134
|
+
};
|
|
135
|
+
})();
|
|
136
|
+
}
|
|
137
|
+
async spend(input) {
|
|
138
|
+
return this.db.transaction(() => {
|
|
139
|
+
if (input.idempotency_key) {
|
|
140
|
+
const existing = this.db
|
|
141
|
+
.prepare("SELECT amount FROM ap_spend_log WHERE user_id = ? AND idempotency_key = ?")
|
|
142
|
+
.get(input.user_id, input.idempotency_key);
|
|
143
|
+
if (existing) {
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
balance: this.balanceSync(input.user_id),
|
|
147
|
+
spent: existing.amount,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const current = this.balanceSync(input.user_id);
|
|
152
|
+
if (current < input.amount) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
code: "insufficient",
|
|
156
|
+
balance: current,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const next = current - input.amount;
|
|
161
|
+
this.db
|
|
162
|
+
.prepare(`INSERT INTO ap_wallet (user_id, balance, updated_at) VALUES (?, ?, ?)
|
|
163
|
+
ON CONFLICT(user_id) DO UPDATE SET balance = ?, updated_at = ?`)
|
|
164
|
+
.run(input.user_id, next, now, next, now);
|
|
165
|
+
if (input.idempotency_key) {
|
|
166
|
+
this.db
|
|
167
|
+
.prepare("INSERT INTO ap_spend_log (user_id, idempotency_key, amount, created_at) VALUES (?, ?, ?, ?)")
|
|
168
|
+
.run(input.user_id, input.idempotency_key, input.amount, now);
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, balance: next, spent: input.amount };
|
|
171
|
+
})();
|
|
172
|
+
}
|
|
173
|
+
balanceSync(userId) {
|
|
174
|
+
const row = this.db
|
|
175
|
+
.prepare("SELECT balance FROM ap_wallet WHERE user_id = ?")
|
|
176
|
+
.get(userId);
|
|
177
|
+
return row?.balance ?? 0;
|
|
178
|
+
}
|
|
179
|
+
addBalance(userId, amount) {
|
|
180
|
+
const now = new Date().toISOString();
|
|
181
|
+
this.db
|
|
182
|
+
.prepare(`INSERT INTO ap_wallet (user_id, balance, updated_at) VALUES (?, ?, ?)
|
|
183
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
184
|
+
balance = balance + excluded.balance,
|
|
185
|
+
updated_at = excluded.updated_at`)
|
|
186
|
+
.run(userId, amount, now);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-specific WHERE-clause builder for time-range filtering.
|
|
3
|
+
*
|
|
4
|
+
* Eliminates duplicated time-filter logic previously inlined in:
|
|
5
|
+
* timeblock.list / stats.summarize / stats.dailyBreakdown / matrix.renderMatrix
|
|
6
|
+
*
|
|
7
|
+
* ADR-0001: SQLite dialect helpers belong in this file, not in the generic repo interface.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Build WHERE sub-clauses for time-range filtering.
|
|
11
|
+
*
|
|
12
|
+
* Date mode (date is set):
|
|
13
|
+
* `column >= :dateT00:00:00 AND column < :dateT23:59:59`
|
|
14
|
+
* Range mode (date absent, from/to optional):
|
|
15
|
+
* `column >= :from` / `column <= :to`
|
|
16
|
+
* Neither:
|
|
17
|
+
* returns empty clauses.
|
|
18
|
+
*/
|
|
19
|
+
export function buildTimeRangeWhere(input) {
|
|
20
|
+
const clauses = [];
|
|
21
|
+
const params = [];
|
|
22
|
+
if (input.date) {
|
|
23
|
+
// Single-day mode — timeblock.list legacy contract:
|
|
24
|
+
// start_time >= dateT00:00:00 AND start_time < dateT23:59:59
|
|
25
|
+
clauses.push(`${input.column} >= ?`, `${input.column} < ?`);
|
|
26
|
+
params.push(`${input.date}T00:00:00`, `${input.date}T23:59:59`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (input.from) {
|
|
30
|
+
clauses.push(`${input.column} >= ?`);
|
|
31
|
+
params.push(input.from);
|
|
32
|
+
}
|
|
33
|
+
if (input.to) {
|
|
34
|
+
clauses.push(`${input.column} <= ?`);
|
|
35
|
+
params.push(input.to);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { clauses, params };
|
|
39
|
+
}
|