categories as dropdown
This commit is contained in:
82
src/categoriesStore.ts
Normal file
82
src/categoriesStore.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import db from "./db.js";
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT id, name, created_at
|
||||
FROM categories
|
||||
ORDER BY name COLLATE NOCASE
|
||||
`);
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO categories (name)
|
||||
VALUES (?)
|
||||
`);
|
||||
|
||||
const deleteStmt = db.prepare(`
|
||||
DELETE FROM categories
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const findByNameStmt = db.prepare(`
|
||||
SELECT id, name, created_at
|
||||
FROM categories
|
||||
WHERE name = ?
|
||||
`);
|
||||
|
||||
const getByIdStmt = db.prepare(`
|
||||
SELECT id, name, created_at
|
||||
FROM categories
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
export function findCategoryByName(name: string): Category | null {
|
||||
const row = findByNameStmt.get(name.trim());
|
||||
return row ? mapCategoryRow(row) : null;
|
||||
}
|
||||
|
||||
export function listCategories(): Category[] {
|
||||
return listStmt.all().map(mapCategoryRow);
|
||||
}
|
||||
|
||||
export function createCategory(name: string): Category {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("Category name must not be empty");
|
||||
}
|
||||
|
||||
const existing = findByNameStmt.get(trimmed);
|
||||
if (existing) {
|
||||
return mapCategoryRow(existing);
|
||||
}
|
||||
|
||||
const info = insertStmt.run(trimmed);
|
||||
const created = getByIdStmt.get(info.lastInsertRowid);
|
||||
if (!created) {
|
||||
throw new Error("Failed to create category");
|
||||
}
|
||||
|
||||
return mapCategoryRow(created);
|
||||
}
|
||||
|
||||
export function getCategory(id: number): Category | null {
|
||||
const row = getByIdStmt.get(id);
|
||||
return row ? mapCategoryRow(row) : null;
|
||||
}
|
||||
|
||||
export function deleteCategory(id: number): boolean {
|
||||
const info = deleteStmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
function mapCategoryRow(row: any): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
@@ -90,11 +90,13 @@ function mapRow(row: ContractDbRow): Contract {
|
||||
}
|
||||
|
||||
function serializePayload(payload: ContractPayload): SerializedPayload {
|
||||
const provider = payload.provider?.trim();
|
||||
const category = payload.category?.trim();
|
||||
return {
|
||||
title: payload.title,
|
||||
paperless_document_id: payload.paperlessDocumentId ?? null,
|
||||
provider: payload.provider ?? null,
|
||||
category: payload.category ?? null,
|
||||
provider: provider && provider.length > 0 ? provider : null,
|
||||
category: category && category.length > 0 ? category : null,
|
||||
contract_start_date: payload.contractStartDate ?? null,
|
||||
contract_end_date: payload.contractEndDate ?? null,
|
||||
termination_notice_days: payload.terminationNoticeDays ?? null,
|
||||
|
||||
26
src/db.ts
26
src/db.ts
@@ -43,8 +43,34 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
const defaultCategories = [
|
||||
"Versicherung",
|
||||
"Strom & Energie",
|
||||
"Internet & Telefon",
|
||||
"Miete & Wohnen",
|
||||
"Mobilfunk",
|
||||
"Streaming & Medien",
|
||||
"Wartung & Service"
|
||||
];
|
||||
|
||||
const categoryRow = db.prepare(`SELECT COUNT(*) as count FROM categories`).get() as { count: number } | undefined;
|
||||
const categoryCount = categoryRow?.count ?? 0;
|
||||
if (categoryCount === 0) {
|
||||
const insertStmt = db.prepare(`INSERT OR IGNORE INTO categories (name) VALUES (?)`);
|
||||
for (const name of defaultCategories) {
|
||||
insertStmt.run(name);
|
||||
}
|
||||
}
|
||||
|
||||
export type ContractDbRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
40
src/index.ts
40
src/index.ts
@@ -10,6 +10,7 @@ import {
|
||||
listUpcomingDeadlines,
|
||||
updateContract
|
||||
} from "./contractsStore.js";
|
||||
import { createCategory, deleteCategory, getCategory, listCategories, findCategoryByName } from "./categoriesStore.js";
|
||||
import { authenticateRequest, createAccessToken, isAuthEnabled, verifyCredentials } from "./auth.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { paperlessClient } from "./paperlessClient.js";
|
||||
@@ -82,6 +83,10 @@ const settingsUpdateSchema = z.object({
|
||||
icalSecret: z.string().min(10).nullable().optional()
|
||||
});
|
||||
|
||||
const categorySchema = z.object({
|
||||
name: z.string().trim().min(1).max(120)
|
||||
});
|
||||
|
||||
function formatSettingsResponse(runtime: RuntimeSettings) {
|
||||
return {
|
||||
values: {
|
||||
@@ -186,6 +191,41 @@ app.post("/auth/login", (req, res) => {
|
||||
res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
app.get("/categories", (_req, res) => {
|
||||
const categories = listCategories();
|
||||
res.json(categories);
|
||||
});
|
||||
|
||||
app.post("/categories", (req, res, next) => {
|
||||
try {
|
||||
const { name } = validatePayload(categorySchema, req.body);
|
||||
const existing = findCategoryByName(name);
|
||||
if (existing) {
|
||||
return res.json(existing);
|
||||
}
|
||||
const category = createCategory(name);
|
||||
res.status(201).json(category);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/categories/:id", (req, res) => {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Invalid category id" });
|
||||
}
|
||||
const category = getCategory(id);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: "Category not found" });
|
||||
}
|
||||
const deleted = deleteCategory(id);
|
||||
if (!deleted) {
|
||||
return res.status(500).json({ error: "Failed to delete category" });
|
||||
}
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
app.get("/calendar/feed.ics", (req, res) => {
|
||||
const providedToken = typeof req.query.token === "string" ? req.query.token : null;
|
||||
const secret = ensureIcalSecret();
|
||||
|
||||
Reference in New Issue
Block a user