Menu
CoddyTech

Fälligkeitstermine

Teil des Abschnitts REST-APIs der Node.js-Journey von Coddy. Lektion 25 von 25.

Aufgaben erhalten ein Fälligkeitsdatum, und das Board zeigt an, welche überfällig sind. Ein Datum ohne Uhrzeit wird als ISO-Zeichenfolge übertragen, 2026-03-15, die zod mit z.iso.date() prüft:

z.iso.date().safeParse('2026-07-01').success   // true
z.iso.date().safeParse('2026-02-30').success   // false: Diesen Tag gibt es nicht
'2026-03-01' < '2026-03-15'                    // true

ISO-Daten haben dieselbe Länge und stellen das Jahr an den Anfang. Wenn du sie also als Zeichenfolgen vergleichst, ergibt sich die zeitliche Reihenfolge. Ein fester Pfad wie /overdue muss im Router vor /:id stehen: Express versucht die Routen der Reihe nach, und /:id passt auf jedes beliebige Wort.

challenge icon

Aufgabe

Einfach

Das Board zeigt, welche Aufgaben überfällig sind. Füge Aufgaben ein optionales Fälligkeitsdatum hinzu und ergänze eine Liste der überfälligen Aufgaben.

  1. Füge in routes/tasks.js dem Schema Task ein optionales Feld due hinzu, das mit z.iso.date() geprüft wird. Es akzeptiert nur echte Datumsangaben im Format YYYY-MM-DD. "2026-03-15" ist gültig, während "15/03/2026", "2026-02-30" und "tomorrow" den üblichen 400-Fehler invalid_body auslösen. Stelle sicher, dass POST due in der neuen Aufgabe speichert.
  2. Füge dem Router GET /api/v1/tasks/overdue hinzu. Deklariere die Route oberhalb der Route /:id, andernfalls behandelt Express overdue als ID und leitet es an /:id weiter.
  3. Die Route liest das Vergleichsdatum aus ?today=, zum Beispiel ?today=2026-03-20. Wenn today fehlt, verwendet sie das aktuelle Datum im Format YYYY-MM-DD. Ein today-Wert, der kein echtes Datum ist, etwa ?today=soon, führt zu Status 400:
    { "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } }
  4. Die Route gibt ein einfaches Aufgaben-Array zurück, nicht das Seitenobjekt, das die Aufgabenliste zurückgibt. Eine Aufgabe ist überfällig, wenn alle drei Bedingungen erfüllt sind: Sie ist nicht erledigt, sie hat ein due-Datum und dieses Datum liegt vor today. Eine Aufgabe, deren Fälligkeitsdatum genau auf today fällt, ist noch nicht überfällig. Sortiere das Array nach due, mit dem frühesten Datum zuerst.

Probiere es aus: Führe in einem frischen Run POST { "title": "Renew the lease", "due": "2026-03-15" } und anschließend { "title": "File the taxes", "due": "2026-03-01" } an /api/v1/tasks aus. Beide geben 201 mit ihrem jeweiligen due-Wert zurück. Anschließend gibt GET /api/v1/tasks/overdue?today=2026-03-20 zuerst das frühere Fälligkeitsdatum zurück (deine createdAt-Zeitangaben können abweichen):

[
  { "id": 5, "title": "File the taxes", "done": false, "labels": [], "due": "2026-03-01", "createdAt": "2026-10-30T09:15:02.118Z" },
  { "id": 4, "title": "Renew the lease", "done": false, "labels": [], "due": "2026-03-15", "createdAt": "2026-10-30T09:14:40.006Z" }
]

GET /api/v1/tasks/overdue?today=2026-03-15 gibt nur „File the taxes“ zurück. GET /api/v1/tasks/overdue?today=soon gibt 400 mit dem Code invalid_query zurück.

Probier es selbst

import { Router } from 'express';
import { z } from 'zod';
import { config } from '../config.js';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// Eine vollständige Aufgabe, wie sie von PUT gesendet wird. .strict() lehnt Felder ab, die nicht aufgeführt sind.
const Task = z
    .object({
        title: z.string().trim().min(1).max(100),
        done: z.boolean(),
        labels: z.array(z.string().trim().min(1).max(20)).max(5).optional(),
    })
    .strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];

// True, wenn eine andere Aufgabe diesen Titel bereits hat, unabhängig von der Groß- und Kleinschreibung.
const titleTaken = (title, exceptId) =>
    tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());

router.get('/', (req, res) => {
    const { done, q, sort, label } = req.query;
    let list = tasks;
    if (done !== undefined) {
        if (done !== 'true' && done !== 'false') {
            return sendError(res, 400, 'invalid_query', 'done must be true or false');
        }
        list = list.filter((t) => t.done === (done === 'true'));
    }
    if (q !== undefined) {
        const words = String(q).toLowerCase();
        list = list.filter((t) => t.title.toLowerCase().includes(words));
    }
    if (label !== undefined) {
        list = list.filter((t) => t.labels.includes(String(label)));
    }
    if (sort !== undefined) {
        const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
        if (!SORT_FIELDS.includes(field)) {
            return sendError(
                res,
                400,
                'invalid_query',
                'sort must be title or createdAt, with - in front to reverse',
            );
        }
        const direction = String(sort).startsWith('-') ? -1 : 1;
        list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
    }
    const page = req.query.page === undefined ? 1 : Number(req.query.page);
    const limit = req.query.limit === undefined ? config.pageSize : Number(req.query.limit);
    if (!Number.isInteger(page) || page < 1) {
        return sendError(res, 400, 'invalid_query', 'page must be a whole number from 1');
    }
    if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
        return sendError(res, 400, 'invalid_query', 'limit must be a whole number from 1 to 50');
    }
    const start = (page - 1) * limit;
    res.json({
        items: list.slice(start, start + limit),
        page,
        total: list.length,
        pages: Math.ceil(list.length / limit),
    });
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const result = NewTask.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { title, done = false, labels = [] } = result.data;
    if (titleTaken(title)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    const task = { id: nextId(), title, done, labels, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/v1/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = TaskChanges.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    Object.assign(task, result.data);
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = Task.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { id, createdAt } = tasks[index];
    if (titleTaken(result.data.title, id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    tasks[index] = { id, ...result.data, labels: result.data.labels ?? [], createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

Alle Lektionen in REST-APIs

Übe selbstständig: Express-Playground