@molecule/api-resource-journal-entry
v1.0.1
Published
Owner-scoped journal entry resource with optional at-rest encryption, mood linkage, daily streaks, and JSON/CSV/TXT export. Extracted from mental-health-journal flagship.
Maintainers
Readme
@molecule/api-resource-journal-entry
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
@molecule/api-resource-journal-entry — owner-scoped diary entries
with optional at-rest encryption, mood-score linkage, daily-write
streaks, and JSON/CSV/TXT export.
Extracted from the mental-health-journal flagship. When
@molecule/api-encryption is bonded, entry bodies are stored
encrypted-at-rest; otherwise the plaintext column is used (the read
path falls back automatically so key rotation never bricks reads).
Quick Start
import { createJournalEntryRouter } from '@molecule/api-resource-journal-entry'
import express from 'express'
const app = express()
app.use('/api/journal', createJournalEntryRouter())Type
resource
Installation
npm install @molecule/api-resource-journal-entry @molecule/api-bonds-default-express @molecule/api-database @molecule/api-encryption @molecule/api-i18n @molecule/api-logger @molecule/api-middleware-validation express zod
npm install -D @types/expressAPI
Interfaces
CreateEntryInput
Input fields accepted when creating a new journal entry.
interface CreateEntryInput {
mood?: MoodLevel
title?: string
body: string
tags?: string[]
prompt_id?: string | null
written_at?: string | Date
}ExportRecord
Flattened, export-friendly representation of a single journal entry.
interface ExportRecord {
id: string
date: string
title: string
mood: MoodLevel
tags: string[]
word_count: number
body: string
}JournalEntryRow
Row shape persisted in journal_entries.
interface JournalEntryRow {
id: string
user_id: string
written_at: string | Date
title: string | null
body: string | null
body_encrypted: string | null
body_iv: string | null
mood_id: string | null
prompt_id: string | null
activity_ids: unknown
tags: unknown
word_count: number
ai_summary: string | null
ai_themes: unknown
is_private: boolean
created_at: string | Date
}MoodEntryRow
Row shape persisted in mood_entries.
interface MoodEntryRow {
id: string
user_id: string
recorded_at: string | Date
score: number
energy: number | null
anxiety: number | null
label: string | null
activities: unknown
notes: string | null
journal_entry_id: string | null
created_at: string | Date
}PublicJournalEntry
Public-facing decrypted entry (returned by the service / routes).
interface PublicJournalEntry {
id: string
date: string
title: string
preview: string
body: string
mood: MoodLevel
tags: string[]
word_count: number
ai_summary: string | null
ai_themes: string[]
prompt: string | null
}UpdateEntryInput
Fields that can be patched on an existing journal entry.
interface UpdateEntryInput {
mood?: MoodLevel
title?: string
body?: string
tags?: string[]
}Types
MoodLevel
Discrete mood-level labels mapped to a 1..5 score.
type MoodLevel = 'radiant' | 'good' | 'neutral' | 'low' | 'struggling'Functions
computeStreak(userId)
Daily-write streak: consecutive distinct UTC days ending today/yesterday.
function computeStreak(userId: string): Promise<number>createEntryForOwner(userId, input)
Create a journal entry and, if mood is provided, upsert today's
mood_entries row + link it to the journal entry.
function createEntryForOwner(
userId: string,
input: CreateEntryInput,
): Promise<PublicJournalEntry | null>createJournalEntryRouter()
Build the journal-entry router.
function createJournalEntryRouter(): RouterdecryptIfNeeded(row)
Decrypt the entry body if encryption is bonded; otherwise return plaintext.
function decryptIfNeeded(row: JournalEntryRow): Promise<string>deleteEntryForOwner(userId, id)
Owner-scoped delete — true if it deleted, false if not owned / missing.
function deleteEntryForOwner(userId: string, id: string): Promise<boolean>exportEntries(userId, max?)
Serialize all entries for an owner into an export-friendly array.
function exportEntries(userId: string, max?: number): Promise<ExportRecord[]>formatExport(records, format)
Format export records as JSON / CSV / TXT.
function formatExport(
records: ExportRecord[],
format: 'json' | 'csv' | 'txt',
): { contentType: string; body: string }getEntryForOwner(userId, id)
Owner-scoped read — returns null when missing or not owned.
function getEntryForOwner(userId: string, id: string): Promise<PublicJournalEntry | null>levelByScore(score)
Round + clamp a raw score and return its discrete label.
function levelByScore(score: number | null | undefined): MoodLevellistEntriesForOwner(userId, limit?)
List the most-recent entries for an owner.
function listEntriesForOwner(userId: string, limit?: number): Promise<PublicJournalEntry[]>normalizeScore(score)
Normalize a 1..5 score to 0..1 (useful for sparkline charts).
function normalizeScore(score: number | null | undefined): numbershapeEntry(entry, body)
Shape a row into the public-facing representation.
function shapeEntry(entry: JournalEntryRow, body: string): Promise<PublicJournalEntry>updateEntryForOwner(userId, id, input)
Owner-scoped patch update — returns null when missing or not owned.
function updateEntryForOwner(
userId: string,
id: string,
input: UpdateEntryInput,
): Promise<PublicJournalEntry | null>Constants
createEntrySchema
Validator for creating a new journal entry.
const createEntrySchema: z.ZodObject<
{
mood: z.ZodOptional<
z.ZodEnum<{
radiant: 'radiant'
good: 'good'
neutral: 'neutral'
low: 'low'
struggling: 'struggling'
}>
>
title: z.ZodOptional<z.ZodString>
body: z.ZodString
tags: z.ZodOptional<z.ZodArray<z.ZodString>>
prompt_id: z.ZodOptional<z.ZodString>
written_at: z.ZodOptional<z.ZodString>
},
z.core.$strip
>moodLevelSchema
Mood level enum used by journal-entry payloads.
const moodLevelSchema: z.ZodEnum<{
radiant: 'radiant'
good: 'good'
neutral: 'neutral'
low: 'low'
struggling: 'struggling'
}>SCORE_BY_LEVEL
Maps each MoodLevel label to its corresponding numeric score (1..5).
const SCORE_BY_LEVEL: Record<MoodLevel, number>updateEntrySchema
Validator for updating an existing journal entry.
const updateEntrySchema: z.ZodObject<
{
mood: z.ZodOptional<
z.ZodOptional<
z.ZodEnum<{
radiant: 'radiant'
good: 'good'
neutral: 'neutral'
low: 'low'
struggling: 'struggling'
}>
>
>
title: z.ZodOptional<z.ZodOptional<z.ZodString>>
tags: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>
prompt_id: z.ZodOptional<z.ZodOptional<z.ZodString>>
written_at: z.ZodOptional<z.ZodOptional<z.ZodString>>
body: z.ZodOptional<z.ZodString>
},
z.core.$strip
>Injection Notes
Requirements
Peer dependencies:
@molecule/api-bonds-default-express^1.0.1@molecule/api-logger^1.0.1@molecule/api-database^1.0.1@molecule/api-encryption^1.0.1@molecule/api-i18n^1.0.1@molecule/api-middleware-validation^1.0.1express^5.0.0zod^4.0.0
Runtime Dependencies
@molecule/api-bonds-default-express@molecule/api-database@molecule/api-encryption@molecule/api-i18n@molecule/api-logger@molecule/api-middleware-validationexpresszod
Schema lives in __setup__/journal_entries.sql — two tables:
journal_entries + mood_entries. Mood rows are upserted per
(user, day) so multiple entries in a day share one mood row.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual journal screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip. This is a PRIVATE journal, so privacy is the defining requirement:
- [ ] Writing an entry persists its real fields — title, body, mood (radiant/good/neutral/low/struggling), tags, and date (written_at) — and the entry then appears in the author's journal list dated correctly (the list is newest-first by written_at) with a word_count matching the body.
- [ ] Editing an entry (title / body / mood / tags) and deleting one both reflect in the UI immediately AND survive a reload — the change persisted server-side, not just local state.
- [ ] If the UI filters or searches the list (by tag, mood, or date), only matching entries come back, and the mood shown per entry matches the mood saved for that day (mood rows upsert once per user per day).
- [ ] If a write streak or count is shown, it equals the number of consecutive distinct days the author actually wrote, counted on the UTC day boundary and ending today or yesterday — add or remove entries across a day boundary and confirm it recomputes; a JSON/CSV/TXT export downloads only the author's own entries.
- [ ] PRIVACY / AUTHORIZATION — entries are visible ONLY to their author. There is no sharing and no cross-user read path: signed in as a second user, requesting another user's entry id returns 404 (not-owned and truly missing are indistinguishable, so existence is never leaked), and the list and export return only your own rows — no admin or global view surfaces another user's entries. The owner is always the session user, never a userId taken from the request body, and journal content is never written to logs in the clear.
