source: app/(app)/add/actions.ts@ 8563782

nextjs
Last change on this file since 8563782 was 8563782, checked in by Vasilaki Tocili <vasilakigorgi@…>, 4 months ago

feature: Implement Add Page(transaction, account, tag)

  • Add form selector dropdown to switch between three form types:
    • Add Transaction form with dynamic From/To Account breakdowns, toggleable tag pills, custom tag input, and optional note field
    • Add Transaction Account form with name and optional initial balance
    • Add Tag form with duplicate-name validation (shared across users)
  • Server actions with auth guards, input validation, and atomic SQL transactions (breakdowns + balance updates + tag assignments)
  • Store notes as note:-prefixed tags to avoid DB schema changes
  • Filter note: tags from history display and show as italic notes
  • Add getAllTags query excluding note: entries
  • Exclude note: tags from history tag filter pills"
  • Property mode set to 100644
File size: 9.5 KB
Line 
1'use server';
2
3import { auth } from '@/auth';
4import { redirect } from 'next/navigation';
5import { sql } from '@/app/lib/db';
6import { revalidatePath } from 'next/cache';
7
8// ─── Shared helpers ────────────────────────────────────────────
9
10export type ActionState = {
11 error?: string;
12 success?: string;
13} | undefined;
14
15async function requireUserId(): Promise<number> {
16 const session = await auth();
17 if (!session?.user?.id) {
18 redirect('/login');
19 }
20 const userId = Number(session.user.id);
21 if (!Number.isInteger(userId)) {
22 redirect('/login');
23 }
24 return userId;
25}
26
27// ─── Add Transaction Account ───────────────────────────────────
28
29export async function addTransactionAccount(
30 _prev: ActionState,
31 formData: FormData,
32): Promise<ActionState> {
33 const userId = await requireUserId();
34
35 const name = String(formData.get('name') ?? '').trim();
36 const balanceRaw = String(formData.get('balance') ?? '0').trim();
37
38 if (!name) {
39 return { error: 'Account name is required.' };
40 }
41
42 const balance = Number(balanceRaw);
43 if (Number.isNaN(balance)) {
44 return { error: 'Balance must be a number.' };
45 }
46
47 try {
48 await sql`
49 INSERT INTO transaction_account (account_name, balance, user_id)
50 VALUES (${name}, ${balance}, ${userId})
51 `;
52 } catch {
53 return { error: 'Failed to create account.' };
54 }
55
56 revalidatePath('/dashboard');
57 revalidatePath('/add');
58 return { success: `Account "${name}" created.` };
59}
60
61// ─── Add Tag ───────────────────────────────────────────────────
62
63export async function addTag(
64 _prev: ActionState,
65 formData: FormData,
66): Promise<ActionState> {
67 await requireUserId(); // auth guard only
68
69 const name = String(formData.get('name') ?? '').trim().toLowerCase();
70
71 if (!name) {
72 return { error: 'Tag name is required.' };
73 }
74 if (name.startsWith('__note:')) {
75 return { error: 'Reserved tag prefix.' };
76 }
77
78 // Check duplicate (case-insensitive)
79 const existing = await sql`
80 SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${name}
81 `;
82 if (existing.length > 0) {
83 return { error: `Tag "${name}" already exists.` };
84 }
85
86 try {
87 await sql`INSERT INTO tag (tag_name) VALUES (${name})`;
88 } catch {
89 return { error: 'Failed to create tag.' };
90 }
91
92 revalidatePath('/add');
93 return { success: `Tag "${name}" created.` };
94}
95
96// ─── Add Transaction ───────────────────────────────────────────
97
98type BreakdownInput = {
99 type: 'from' | 'to';
100 accountId: number;
101 amount: number;
102};
103
104export async function addTransaction(
105 _prev: ActionState,
106 formData: FormData,
107): Promise<ActionState> {
108 const userId = await requireUserId();
109
110 const name = String(formData.get('name') ?? '').trim();
111 const date = String(formData.get('date') ?? '').trim();
112 const amountRaw = String(formData.get('amount') ?? '').trim();
113 const tagsJson = String(formData.get('tags') ?? '[]');
114 const note = String(formData.get('note') ?? '').trim();
115 const breakdownsJson = String(formData.get('breakdowns') ?? '[]');
116
117 // ── Validation ──
118 if (!name) {
119 return { error: 'Transaction name is required.' };
120 }
121 if (!date) {
122 return { error: 'Date is required.' };
123 }
124
125 const amount = Number(amountRaw);
126 if (!amountRaw || Number.isNaN(amount)) {
127 return { error: 'Amount must be a number.' };
128 }
129
130 let tags: string[];
131 try {
132 tags = JSON.parse(tagsJson);
133 if (!Array.isArray(tags)) {
134 throw new Error();
135 }
136 } catch {
137 return { error: 'Invalid tags data.' };
138 }
139
140 let breakdowns: BreakdownInput[];
141 try {
142 breakdowns = JSON.parse(breakdownsJson);
143 if (!Array.isArray(breakdowns)) {
144 throw new Error();
145 }
146 } catch {
147 return { error: 'Invalid breakdowns data.' };
148 }
149
150 // Validate each breakdown
151 for (const breakdown of breakdowns) {
152 if (!['from', 'to'].includes(breakdown.type)) {
153 return { error: 'Invalid breakdown type.' };
154 }
155 if (!Number.isFinite(breakdown.amount) || breakdown.amount <= 0) {
156 return { error: 'Breakdown amounts must be positive.' };
157 }
158 if (!Number.isInteger(breakdown.accountId)) {
159 return { error: 'Invalid account in breakdown.' };
160 }
161 }
162
163 // Verify all accounts belong to this user
164 if (breakdowns.length > 0) {
165 const accountIds = [...new Set(breakdowns.map((b) => b.accountId))];
166 const ownedAccounts = await sql`
167 SELECT transaction_account_id
168 FROM transaction_account
169 WHERE user_id = ${userId}
170 AND transaction_account_id = ANY(${accountIds}::int[])
171 `;
172 if (ownedAccounts.length !== accountIds.length) {
173 return { error: 'One or more accounts do not belong to you.' };
174 }
175 }
176
177 // Compute net_amount from breakdowns
178 let totalEarned = 0;
179 let totalSpent = 0;
180 for (const breakdown of breakdowns) {
181 if (breakdown.type === 'to') {
182 totalEarned += breakdown.amount;
183 }
184 else {
185 totalSpent += breakdown.amount;
186 }
187 }
188 const netAmount = totalEarned - totalSpent;
189
190 try {
191 // Use a SQL transaction for atomicity
192 // eslint-disable-next-line @typescript-eslint/no-explicit-any
193 await sql.begin(async (tx: any) => {
194 // 1. Insert transaction
195 const [txRow] = await tx`
196 INSERT INTO transaction (transaction_name, amount, net_amount, date)
197 VALUES (${name}, ${amount}, ${netAmount}, ${date})
198 RETURNING transaction_id
199 `;
200 const transactionId: number = txRow.transaction_id;
201
202 // 2. Insert breakdowns & update account balances
203 for (const breakdown of breakdowns) {
204 const spent = breakdown.type === 'from' ? breakdown.amount : 0;
205 const earned = breakdown.type === 'to' ? breakdown.amount : 0;
206
207 await tx`
208 INSERT INTO transaction_breakdown
209 (transaction_id, transaction_account_id, spent_amount, earned_amount)
210 VALUES (${transactionId}, ${breakdown.accountId}, ${spent}, ${earned})
211 `;
212
213 // Update balance: earned increases, spent decreases
214 await tx`
215 UPDATE transaction_account
216 SET balance = balance + ${earned} - ${spent}
217 WHERE transaction_account_id = ${breakdown.accountId}
218 `;
219 }
220
221 // 3. Handle tags
222 for (const tagName of tags) {
223 const trimmed = tagName.trim().toLowerCase();
224 if (!trimmed || trimmed.startsWith('__note:')) {
225 continue;
226 }
227
228 // Find or insert tag
229 const existing = await tx`
230 SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${trimmed}
231 `;
232 let tagId: number;
233 if (existing.length > 0) {
234 tagId = existing[0].tag_id;
235 } else {
236 const [inserted] = await tx`
237 INSERT INTO tag (tag_name) VALUES (${trimmed})
238 RETURNING tag_id
239 `;
240 tagId = inserted.tag_id;
241 }
242
243 // Check if assignment already exists
244 const existingAssignment = await tx`
245 SELECT tag_assigned_to_transaction_id
246 FROM tag_assigned_to_transaction
247 WHERE transaction_id = ${transactionId} AND tag_id = ${tagId}
248 `;
249 if (existingAssignment.length === 0) {
250 await tx`
251 INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
252 VALUES (${transactionId}, ${tagId})
253 `;
254 }
255 }
256
257 // 4. Handle note as a special __note: tag
258 if (note) {
259 const noteTagName = `__note:${note}`;
260
261 const existingNote = await tx`
262 SELECT tag_id FROM tag WHERE tag_name = ${noteTagName}
263 `;
264 let noteTagId: number;
265 if (existingNote.length > 0) {
266 noteTagId = existingNote[0].tag_id;
267 } else {
268 const [inserted] = await tx`
269 INSERT INTO tag (tag_name) VALUES (${noteTagName})
270 RETURNING tag_id
271 `;
272 noteTagId = inserted.tag_id;
273 }
274
275 await tx`
276 INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
277 VALUES (${transactionId}, ${noteTagId})
278 `;
279 }
280 });
281 } catch (e) {
282 console.error('addTransaction error:', e);
283 return { error: 'Failed to create transaction.' };
284 }
285
286 revalidatePath('/dashboard');
287 revalidatePath('/history');
288 revalidatePath('/add');
289 return { success: `Transaction "${name}" created.` };
290}
Note: See TracBrowser for help on using the repository browser.