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

nextjs
Last change on this file was b04ba1e, checked in by Vasilaki Totsili <vasilaki@…>, 2 days ago

refactor: implement transaction management for db operations

Replaced single transaction operations with transaction blocks for
critical database mutations to ensure atomicity and consistency,
preventing race conditions and ensuring that operations like checking
for existing records and subsequent inserts or updates are atomic:

  • Implement transactional blocks for addTag, updateProfile and register functions to handle duplicate checks and data insertion/updating in a single transaction scope
  • Handle specific error messages in a more controlled manner by checking exception types and messages
  • Property mode set to 100644
File size: 10.1 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 try {
79 // eslint-disable-next-line @typescript-eslint/no-explicit-any
80 await sql.begin(async (tx: any) => {
81 // Check duplicate (case-insensitive)
82 const existing = await tx`
83 SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${name}
84 `;
85 if (existing.length > 0) {
86 throw new Error(`Tag "${name}" already exists.`);
87 }
88
89 await tx`INSERT INTO tag (tag_name) VALUES (${name})`;
90 });
91 } catch (e: any) {
92 if (e instanceof Error && e.message.includes('already exists')) {
93 return { error: e.message };
94 }
95 return { error: 'Failed to create tag.' };
96 }
97
98 revalidatePath('/add');
99 return { success: `Tag "${name}" created.` };
100}
101
102// ─── Add Transaction ───────────────────────────────────────────
103
104type BreakdownInput = {
105 type: 'from' | 'to';
106 accountId: number;
107 amount: number;
108};
109
110export async function addTransaction(
111 _prev: ActionState,
112 formData: FormData,
113): Promise<ActionState> {
114 const userId = await requireUserId();
115
116 const name = String(formData.get('name') ?? '').trim();
117 const date = String(formData.get('date') ?? '').trim();
118 const amountRaw = String(formData.get('amount') ?? '').trim();
119 const tagsJson = String(formData.get('tags') ?? '[]');
120 const pendingTag = String(formData.get('pendingTag') ?? '').trim().toLowerCase();
121 const note = String(formData.get('note') ?? '').trim();
122 const breakdownsJson = String(formData.get('breakdowns') ?? '[]');
123
124 // ── Validation ──
125 if (!name) {
126 return { error: 'Transaction name is required.' };
127 }
128 if (!date) {
129 return { error: 'Date is required.' };
130 }
131
132 const amount = Number(amountRaw);
133 if (!amountRaw || Number.isNaN(amount)) {
134 return { error: 'Amount must be a number.' };
135 }
136
137 let tags: string[];
138 try {
139 tags = JSON.parse(tagsJson);
140 if (!Array.isArray(tags)) {
141 throw new Error();
142 }
143 } catch {
144 return { error: 'Invalid tags data.' };
145 }
146
147 // Include any tag the user typed but didn't press Enter for
148 if (pendingTag && !pendingTag.startsWith('__note:') && !tags.includes(pendingTag)) {
149 tags.push(pendingTag);
150 }
151
152 let breakdowns: BreakdownInput[];
153 try {
154 breakdowns = JSON.parse(breakdownsJson);
155 if (!Array.isArray(breakdowns)) {
156 throw new Error();
157 }
158 } catch {
159 return { error: 'Invalid breakdowns data.' };
160 }
161
162 // Validate each breakdown
163 for (const breakdown of breakdowns) {
164 if (!['from', 'to'].includes(breakdown.type)) {
165 return { error: 'Invalid breakdown type.' };
166 }
167 if (!Number.isFinite(breakdown.amount) || breakdown.amount <= 0) {
168 return { error: 'Breakdown amounts must be positive.' };
169 }
170 if (!Number.isInteger(breakdown.accountId)) {
171 return { error: 'Invalid account in breakdown.' };
172 }
173 }
174
175 // Verify all accounts belong to this user
176 if (breakdowns.length > 0) {
177 const accountIds = [...new Set(breakdowns.map((b) => b.accountId))];
178 const ownedAccounts = await sql`
179 SELECT transaction_account_id
180 FROM transaction_account
181 WHERE user_id = ${userId}
182 AND transaction_account_id = ANY(${accountIds}::int[])
183 `;
184 if (ownedAccounts.length !== accountIds.length) {
185 return { error: 'One or more accounts do not belong to you.' };
186 }
187 }
188
189 // Compute net_amount from breakdowns
190 let totalEarned = 0;
191 let totalSpent = 0;
192 for (const breakdown of breakdowns) {
193 if (breakdown.type === 'to') {
194 totalEarned += breakdown.amount;
195 }
196 else {
197 totalSpent += breakdown.amount;
198 }
199 }
200 const netAmount = totalEarned - totalSpent;
201
202 try {
203 // Use a SQL transaction for atomicity
204 // eslint-disable-next-line @typescript-eslint/no-explicit-any
205 await sql.begin(async (tx: any) => {
206 // 1. Insert transaction
207 const [txRow] = await tx`
208 INSERT INTO transaction (transaction_name, amount, net_amount, date)
209 VALUES (${name}, ${amount}, ${netAmount}, ${date})
210 RETURNING transaction_id
211 `;
212 const transactionId: number = txRow.transaction_id;
213
214 // 2. Insert breakdowns & update account balances
215 for (const breakdown of breakdowns) {
216 const spent = breakdown.type === 'from' ? breakdown.amount : 0;
217 const earned = breakdown.type === 'to' ? breakdown.amount : 0;
218
219 await tx`
220 INSERT INTO transaction_breakdown
221 (transaction_id, transaction_account_id, spent_amount, earned_amount)
222 VALUES (${transactionId}, ${breakdown.accountId}, ${spent}, ${earned})
223 `;
224
225 // Update balance: earned increases, spent decreases
226 await tx`
227 UPDATE transaction_account
228 SET balance = balance + ${earned} - ${spent}
229 WHERE transaction_account_id = ${breakdown.accountId}
230 `;
231 }
232
233 // 3. Handle tags
234 for (const tagName of tags) {
235 const trimmed = tagName.trim().toLowerCase();
236 if (!trimmed || trimmed.startsWith('__note:')) {
237 continue;
238 }
239
240 // Find or insert tag
241 const existing = await tx`
242 SELECT tag_id FROM tag WHERE LOWER(tag_name) = ${trimmed}
243 `;
244 let tagId: number;
245 if (existing.length > 0) {
246 tagId = existing[0].tag_id;
247 } else {
248 const [inserted] = await tx`
249 INSERT INTO tag (tag_name) VALUES (${trimmed})
250 RETURNING tag_id
251 `;
252 tagId = inserted.tag_id;
253 }
254
255 // Check if assignment already exists
256 const existingAssignment = await tx`
257 SELECT tag_assigned_to_transaction_id
258 FROM tag_assigned_to_transaction
259 WHERE transaction_id = ${transactionId} AND tag_id = ${tagId}
260 `;
261 if (existingAssignment.length === 0) {
262 await tx`
263 INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
264 VALUES (${transactionId}, ${tagId})
265 `;
266 }
267 }
268
269 // 4. Handle note as a special __note: tag
270 if (note) {
271 const noteTagName = `__note:${note}`;
272
273 const existingNote = await tx`
274 SELECT tag_id FROM tag WHERE tag_name = ${noteTagName}
275 `;
276 let noteTagId: number;
277 if (existingNote.length > 0) {
278 noteTagId = existingNote[0].tag_id;
279 } else {
280 const [inserted] = await tx`
281 INSERT INTO tag (tag_name) VALUES (${noteTagName})
282 RETURNING tag_id
283 `;
284 noteTagId = inserted.tag_id;
285 }
286
287 await tx`
288 INSERT INTO tag_assigned_to_transaction (transaction_id, tag_id)
289 VALUES (${transactionId}, ${noteTagId})
290 `;
291 }
292 });
293 } catch (e) {
294 console.error('addTransaction error:', e);
295 return { error: 'Failed to create transaction.' };
296 }
297
298 revalidatePath('/dashboard');
299 revalidatePath('/history');
300 revalidatePath('/add');
301 return { success: `Transaction "${name}" created.` };
302}
Note: See TracBrowser for help on using the repository browser.