v1.1.0 Kalendarz i listy zakupów
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 3m10s

Dodaje wspólny kalendarz wydarzeń grupy (zakres dat/godzin, cały dzień,
notatki, lokalizacja, uczestnicy z gospodarstwa + goście zewnętrzni)
oraz listy zakupów (wiele list, dodawanie/edycja/odznaczanie/usuwanie
produktów). Nowe tabele w schemacie SQLite są czysto addytywne
(CREATE TABLE IF NOT EXISTS), więc nie ruszają istniejących danych.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 23:26:00 +02:00
parent 350ccdbaf7
commit 9c39b33636
28 changed files with 1537 additions and 9 deletions

View File

@@ -78,6 +78,50 @@ CREATE TABLE IF NOT EXISTS settlements (
settled_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
notes TEXT,
location TEXT,
all_day INTEGER NOT NULL DEFAULT 0,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS event_attendees (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
guest_name TEXT
);
CREATE TABLE IF NOT EXISTS shopping_lists (
id TEXT PRIMARY KEY,
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS shopping_list_items (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT,
checked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_expenses_household ON expenses(household_id);
CREATE INDEX IF NOT EXISTS idx_expense_shares_expense ON expense_shares(expense_id);
CREATE INDEX IF NOT EXISTS idx_settlements_household ON settlements(household_id);
CREATE INDEX IF NOT EXISTS idx_events_household ON events(household_id);
CREATE INDEX IF NOT EXISTS idx_event_attendees_event ON event_attendees(event_id);
CREATE INDEX IF NOT EXISTS idx_shopping_lists_household ON shopping_lists(household_id);
CREATE INDEX IF NOT EXISTS idx_shopping_list_items_list ON shopping_list_items(list_id);

View File

@@ -8,6 +8,8 @@ const { router: categoriesRouter } = require('./routes/categories');
const { router: expensesRouter } = require('./routes/expenses');
const { router: settlementsRouter } = require('./routes/settlements');
const { router: statsRouter } = require('./routes/stats');
const { router: eventsRouter } = require('./routes/events');
const { router: shoppingListsRouter } = require('./routes/shoppingLists');
const app = express();
app.use(cors());
@@ -21,6 +23,8 @@ app.use('/categories', categoriesRouter);
app.use('/expenses', expensesRouter);
app.use('/settlements', settlementsRouter);
app.use('/stats', statsRouter);
app.use('/events', eventsRouter);
app.use('/shopping-lists', shoppingListsRouter);
app.use((err, req, res, next) => {
console.error(err);

View File

@@ -0,0 +1,183 @@
const express = require('express');
const { v4: uuid } = require('uuid');
const { db } = require('../db/db');
const { requireAuth } = require('../middleware/auth');
const { requireHousehold, getMembers } = require('../utils/households');
const router = express.Router();
router.use(requireAuth, requireHousehold);
function attachAttendees(event) {
const rows = db
.prepare(
`SELECT ea.user_id, ea.guest_name, u.name, u.email FROM event_attendees ea
LEFT JOIN users u ON u.id = ea.user_id
WHERE ea.event_id = ?`
)
.all(event.id);
const attendees = rows.map((r) => ({
userId: r.user_id,
name: r.user_id ? r.name : r.guest_name,
email: r.email || null,
isGuest: !r.user_id,
}));
return { ...event, attendees };
}
function validateEventBody(body, members) {
const { title, startAt, endAt, attendeeUserIds, guestNames } = body || {};
if (!title || !startAt || !endAt) {
throw new Error('missing_event_fields');
}
if (endAt < startAt) {
throw new Error('event_end_before_start');
}
const memberIds = members.map((m) => m.id);
for (const userId of attendeeUserIds || []) {
if (!memberIds.includes(userId)) {
throw new Error('attendee_must_be_member');
}
}
for (const name of guestNames || []) {
if (!name || !name.trim()) {
throw new Error('guest_name_required');
}
}
}
router.get('/', (req, res) => {
const events = db
.prepare('SELECT * FROM events WHERE household_id = ? ORDER BY start_at')
.all(req.household.id)
.map(attachAttendees);
res.json({ events });
});
router.post('/', (req, res) => {
const { title, notes, location, allDay, startAt, endAt, attendeeUserIds, guestNames } = req.body || {};
const members = getMembers(req.household.id);
try {
validateEventBody(req.body, members);
} catch (err) {
return res.status(400).json({ error: err.message });
}
const event = {
id: uuid(),
household_id: req.household.id,
created_by: req.userId,
title,
notes: notes || null,
location: location || null,
all_day: allDay ? 1 : 0,
start_at: startAt,
end_at: endAt,
};
const insert = db.transaction(() => {
db.prepare(
`INSERT INTO events (id, household_id, created_by, title, notes, location, all_day, start_at, end_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
event.id,
event.household_id,
event.created_by,
event.title,
event.notes,
event.location,
event.all_day,
event.start_at,
event.end_at
);
for (const userId of attendeeUserIds || []) {
db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, ?, NULL)').run(
uuid(),
event.id,
userId
);
}
for (const guestName of guestNames || []) {
db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, NULL, ?)').run(
uuid(),
event.id,
guestName.trim()
);
}
});
insert();
const created = db.prepare('SELECT * FROM events WHERE id = ?').get(event.id);
res.status(201).json({ event: attachAttendees(created) });
});
router.put('/:id', (req, res) => {
const existing = db
.prepare('SELECT * FROM events WHERE id = ? AND household_id = ?')
.get(req.params.id, req.household.id);
if (!existing) {
return res.status(404).json({ error: 'event_not_found' });
}
const { title, notes, location, allDay, startAt, endAt, attendeeUserIds, guestNames } = req.body || {};
const members = getMembers(req.household.id);
const merged = {
title: title ?? existing.title,
startAt: startAt ?? existing.start_at,
endAt: endAt ?? existing.end_at,
attendeeUserIds,
guestNames,
};
try {
validateEventBody(merged, members);
} catch (err) {
return res.status(400).json({ error: err.message });
}
const update = db.transaction(() => {
db.prepare(
`UPDATE events SET title = ?, notes = ?, location = ?, all_day = ?, start_at = ?, end_at = ?,
updated_at = datetime('now') WHERE id = ?`
).run(
merged.title,
notes !== undefined ? notes : existing.notes,
location !== undefined ? location : existing.location,
allDay !== undefined ? (allDay ? 1 : 0) : existing.all_day,
merged.startAt,
merged.endAt,
existing.id
);
db.prepare('DELETE FROM event_attendees WHERE event_id = ?').run(existing.id);
for (const userId of attendeeUserIds || []) {
db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, ?, NULL)').run(
uuid(),
existing.id,
userId
);
}
for (const guestName of guestNames || []) {
db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, NULL, ?)').run(
uuid(),
existing.id,
guestName.trim()
);
}
});
update();
const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(existing.id);
res.json({ event: attachAttendees(updated) });
});
router.delete('/:id', (req, res) => {
const result = db
.prepare('DELETE FROM events WHERE id = ? AND household_id = ?')
.run(req.params.id, req.household.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'event_not_found' });
}
res.status(204).end();
});
module.exports = { router };

View File

@@ -0,0 +1,151 @@
const express = require('express');
const { v4: uuid } = require('uuid');
const { db } = require('../db/db');
const { requireAuth } = require('../middleware/auth');
const { requireHousehold } = require('../utils/households');
const router = express.Router();
router.use(requireAuth, requireHousehold);
function getItems(listId) {
return db
.prepare('SELECT * FROM shopping_list_items WHERE list_id = ? ORDER BY checked, created_at')
.all(listId);
}
function getListOr404(req, res) {
const list = db
.prepare('SELECT * FROM shopping_lists WHERE id = ? AND household_id = ?')
.get(req.params.id, req.household.id);
if (!list) {
res.status(404).json({ error: 'shopping_list_not_found' });
return null;
}
return list;
}
router.get('/', (req, res) => {
const lists = db
.prepare(
`SELECT sl.*, COUNT(i.id) AS total, COALESCE(SUM(i.checked), 0) AS checkedCount
FROM shopping_lists sl
LEFT JOIN shopping_list_items i ON i.list_id = sl.id
WHERE sl.household_id = ?
GROUP BY sl.id
ORDER BY sl.created_at`
)
.all(req.household.id);
res.json({ lists });
});
router.post('/', (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'shopping_list_name_required' });
}
const list = {
id: uuid(),
household_id: req.household.id,
name: name.trim(),
created_by: req.userId,
};
db.prepare('INSERT INTO shopping_lists (id, household_id, name, created_by) VALUES (?, ?, ?, ?)').run(
list.id,
list.household_id,
list.name,
list.created_by
);
const created = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(list.id);
res.status(201).json({ list: { ...created, total: 0, checkedCount: 0 } });
});
router.get('/:id', (req, res) => {
const list = getListOr404(req, res);
if (!list) return;
res.json({ list: { ...list, items: getItems(list.id) } });
});
router.put('/:id', (req, res) => {
const list = getListOr404(req, res);
if (!list) return;
const { name } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'shopping_list_name_required' });
}
db.prepare(`UPDATE shopping_lists SET name = ?, updated_at = datetime('now') WHERE id = ?`).run(
name.trim(),
list.id
);
const updated = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(list.id);
res.json({ list: { ...updated, items: getItems(list.id) } });
});
router.delete('/:id', (req, res) => {
const list = getListOr404(req, res);
if (!list) return;
db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(list.id);
res.status(204).end();
});
router.post('/:id/items', (req, res) => {
const list = getListOr404(req, res);
if (!list) return;
const { name, quantity } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'shopping_item_name_required' });
}
const item = {
id: uuid(),
list_id: list.id,
name: name.trim(),
quantity: quantity || null,
};
db.prepare('INSERT INTO shopping_list_items (id, list_id, name, quantity) VALUES (?, ?, ?, ?)').run(
item.id,
item.list_id,
item.name,
item.quantity
);
db.prepare(`UPDATE shopping_lists SET updated_at = datetime('now') WHERE id = ?`).run(list.id);
const created = db.prepare('SELECT * FROM shopping_list_items WHERE id = ?').get(item.id);
res.status(201).json({ item: created });
});
router.put('/:id/items/:itemId', (req, res) => {
const list = getListOr404(req, res);
if (!list) return;
const existing = db
.prepare('SELECT * FROM shopping_list_items WHERE id = ? AND list_id = ?')
.get(req.params.itemId, list.id);
if (!existing) {
return res.status(404).json({ error: 'shopping_item_not_found' });
}
const { name, quantity, checked } = req.body || {};
if (name !== undefined && !name.trim()) {
return res.status(400).json({ error: 'shopping_item_name_required' });
}
db.prepare(
`UPDATE shopping_list_items SET name = ?, quantity = ?, checked = ?, updated_at = datetime('now') WHERE id = ?`
).run(
name !== undefined ? name.trim() : existing.name,
quantity !== undefined ? quantity : existing.quantity,
checked !== undefined ? (checked ? 1 : 0) : existing.checked,
existing.id
);
const updated = db.prepare('SELECT * FROM shopping_list_items WHERE id = ?').get(existing.id);
res.json({ item: updated });
});
router.delete('/:id/items/:itemId', (req, res) => {
const list = getListOr404(req, res);
if (!list) return;
const result = db
.prepare('DELETE FROM shopping_list_items WHERE id = ? AND list_id = ?')
.run(req.params.itemId, list.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'shopping_item_not_found' });
}
res.status(204).end();
});
module.exports = { router };