v1.1.0 Kalendarz i listy zakupów
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 3m10s
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
183
backend/src/routes/events.js
Normal file
183
backend/src/routes/events.js
Normal 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 };
|
||||
151
backend/src/routes/shoppingLists.js
Normal file
151
backend/src/routes/shoppingLists.js
Normal 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 };
|
||||
Reference in New Issue
Block a user