feat(server): initial implementation with injectable database and testable server - Set up project structure, database schema, and GraphQL API. Implement injectable Knex database for clean testability. Add test printer, migrations, and unit tests. All server and test code is clean, modular, and ready for further development.

This commit is contained in:
Sean Sube 2025-06-14 13:45:25 -05:00
commit 75f30b3a16
No known key found for this signature in database
GPG Key ID: 3EED7B957D362AF1
17 changed files with 8072 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
# Database
*.sqlite3
*.sqlite3-journal
# Test output
test-output/
# Environment variables
.env
.env.local
.env.*.local
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# Task Receipts
A task management system with receipt printer integration, designed to help people with ADD keep track of their tasks. The system prints tasks and steps to a receipt printer, making it easy to have physical copies of instructions.
## Features
- Group tasks into categories
- Break down tasks into steps with detailed instructions
- Print tasks and steps to a receipt printer
- Track frequently used and recently printed tasks
- Support for images in instructions (automatically converted to black and white for printing)
- Mobile and desktop web interface
- GraphQL API for efficient data fetching
## Prerequisites
- Node.js 18 or later
- SQLite 3
- USB receipt printer (optional, test mode available)
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/task-receipts.git
cd task-receipts
```
2. Install dependencies:
```bash
npm install
```
3. Set up the database:
```bash
npx knex migrate:latest
```
4. Start the development server:
```bash
npm run dev
```
The server will start at http://localhost:3000, and the GraphQL endpoint will be available at http://localhost:3000/graphql.
## Development
- `npm run dev` - Start the development server
- `npm run build` - Build the project
- `npm start` - Start the production server
- `npm test` - Run tests
## Testing
The project includes a test printer implementation that writes receipts to text files in the `test-output` directory. This makes it easy to test the printing functionality without a physical printer.
## License
MIT

12
jest.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/src/server/db/__tests__/setup.ts'],
};

26
knexfile.ts Normal file
View File

@ -0,0 +1,26 @@
import type { Knex } from 'knex';
const config: { [key: string]: Knex.Config } = {
development: {
client: 'sqlite3',
connection: {
filename: './dev.sqlite3'
},
useNullAsDefault: true,
migrations: {
directory: './src/server/db/migrations'
}
},
test: {
client: 'sqlite3',
connection: {
filename: ':memory:'
},
useNullAsDefault: true,
migrations: {
directory: './src/server/db/migrations'
}
}
};
export default config;

7306
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "task-receipts",
"version": "1.0.0",
"description": "Task management system with receipt printer integration",
"main": "dist/server/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/server/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/server/index.ts",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@apollo/server": "^4.10.0",
"express": "^4.18.2",
"graphql": "^16.8.1",
"knex": "^3.1.0",
"sqlite3": "^5.1.7",
"typescript": "^5.3.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.5",
"@types/sqlite3": "^3.1.11",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node-dev": "^2.0.0"
}
}

View File

@ -0,0 +1,132 @@
import { testDb } from './setup';
import { groups, tasks, steps, images } from '../index';
import { Group, Task, Step, Image } from '../types';
describe('Database Operations', () => {
beforeEach(async () => {
// Clean up the database before each test
await images(testDb).del();
await steps(testDb).del();
await tasks(testDb).del();
await groups(testDb).del();
});
afterAll(async () => {
// Close the database connection after all tests
await testDb.destroy();
});
describe('Groups', () => {
it('should create and retrieve a group', async () => {
const groupData = {
name: 'Test Group',
parent_id: null,
};
const [id] = await groups(testDb).insert(groupData);
const group = await groups(testDb).where('id', id).first();
expect(group).toMatchObject({
id,
name: groupData.name,
parent_id: groupData.parent_id,
});
});
it('should create a nested group', async () => {
const [parentId] = await groups(testDb).insert({ name: 'Parent Group' });
const [childId] = await groups(testDb).insert({
name: 'Child Group',
parent_id: parentId,
});
const retrievedChild = await groups(testDb).where('id', childId).first();
expect(retrievedChild?.parent_id).toBe(parentId);
});
});
describe('Tasks', () => {
it('should create and retrieve a task', async () => {
const [groupId] = await groups(testDb).insert({ name: 'Test Group' });
const taskData = {
title: 'Test Task',
group_id: groupId,
print_count: 0,
};
const [id] = await tasks(testDb).insert(taskData);
const task = await tasks(testDb).where('id', id).first();
expect(task).toMatchObject({
id,
title: taskData.title,
group_id: taskData.group_id,
print_count: taskData.print_count,
});
});
});
describe('Steps', () => {
it('should create and retrieve a step', async () => {
const [groupId] = await groups(testDb).insert({ name: 'Test Group' });
const [taskId] = await tasks(testDb).insert({
title: 'Test Task',
group_id: groupId,
});
const stepData = {
title: 'Test Step',
instructions: 'Test Instructions',
task_id: taskId,
order: 1,
print_count: 0,
};
const [id] = await steps(testDb).insert(stepData);
const step = await steps(testDb).where('id', id).first();
expect(step).toMatchObject({
id,
title: stepData.title,
instructions: stepData.instructions,
task_id: stepData.task_id,
order: stepData.order,
print_count: stepData.print_count,
});
});
});
describe('Images', () => {
it('should create and retrieve an image', async () => {
const [groupId] = await groups(testDb).insert({ name: 'Test Group' });
const [taskId] = await tasks(testDb).insert({
title: 'Test Task',
group_id: groupId,
});
const [stepId] = await steps(testDb).insert({
title: 'Test Step',
instructions: 'Test Instructions',
task_id: taskId,
order: 1,
});
const imageData = {
step_id: stepId,
original_path: '/path/to/original.png',
bw_path: '/path/to/bw.png',
order: 1,
};
const [id] = await images(testDb).insert(imageData);
const image = await images(testDb).where('id', id).first();
expect(image).toMatchObject({
id,
step_id: imageData.step_id,
original_path: imageData.original_path,
bw_path: imageData.bw_path,
order: imageData.order,
});
});
});
});

View File

@ -0,0 +1,14 @@
import knex from 'knex';
import config from '../../../../knexfile';
export const testDb = knex(config.test);
beforeAll(async () => {
// Run migrations
await testDb.migrate.latest();
});
afterAll(async () => {
// Close the database connection
await testDb.destroy();
});

20
src/server/db/index.ts Normal file
View File

@ -0,0 +1,20 @@
import knex, { Knex } from 'knex';
import config from '../../../knexfile';
import { Group, Task, Step, Image } from './types';
export function createDb(env: string = process.env.NODE_ENV || 'development') {
return knex(config[env]);
}
export function groups(db: Knex) {
return db<Group>('groups');
}
export function tasks(db: Knex) {
return db<Task>('tasks');
}
export function steps(db: Knex) {
return db<Step>('steps');
}
export function images(db: Knex) {
return db<Image>('images');
}

View File

@ -0,0 +1,50 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
// Create groups table
await knex.schema.createTable('groups', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.integer('parent_id').references('id').inTable('groups');
table.timestamps(true, true);
});
// Create tasks table
await knex.schema.createTable('tasks', (table) => {
table.increments('id').primary();
table.string('title').notNullable();
table.integer('group_id').references('id').inTable('groups').notNullable();
table.integer('print_count').defaultTo(0);
table.timestamp('last_printed_at');
table.timestamps(true, true);
});
// Create steps table
await knex.schema.createTable('steps', (table) => {
table.increments('id').primary();
table.string('title').notNullable();
table.text('instructions').notNullable();
table.integer('task_id').references('id').inTable('tasks').notNullable();
table.integer('order').notNullable();
table.integer('print_count').defaultTo(0);
table.timestamp('last_printed_at');
table.timestamps(true, true);
});
// Create images table for step images
await knex.schema.createTable('images', (table) => {
table.increments('id').primary();
table.integer('step_id').references('id').inTable('steps').notNullable();
table.string('original_path').notNullable();
table.string('bw_path').notNullable();
table.integer('order').notNullable();
table.timestamps(true, true);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('images');
await knex.schema.dropTable('steps');
await knex.schema.dropTable('tasks');
await knex.schema.dropTable('groups');
}

39
src/server/db/types.ts Normal file
View File

@ -0,0 +1,39 @@
export interface Group {
id: number;
name: string;
parent_id: number | null;
created_at: string;
updated_at: string;
}
export interface Task {
id: number;
title: string;
group_id: number;
print_count: number;
last_printed_at: string | null;
created_at: string;
updated_at: string;
}
export interface Step {
id: number;
title: string;
instructions: string;
task_id: number;
order: number;
print_count: number;
last_printed_at: string | null;
created_at: string;
updated_at: string;
}
export interface Image {
id: number;
step_id: number;
original_path: string;
bw_path: string;
order: number;
created_at: string;
updated_at: string;
}

View File

@ -0,0 +1,139 @@
import { Knex } from 'knex';
import { groups, tasks, steps, images } from '../db';
import { printTask, printStep } from '../printer';
interface Context {
db: Knex;
}
export const resolvers = {
Query: {
groups: async (_: any, __: any, { db }: Context) => {
return await groups(db).select('*');
},
group: async (_: any, { id }: { id: string }, { db }: Context) => {
return await groups(db).where('id', id).first();
},
tasks: async (_: any, { groupId }: { groupId: string }, { db }: Context) => {
return await tasks(db).where('group_id', groupId).select('*');
},
task: async (_: any, { id }: { id: string }, { db }: Context) => {
return await tasks(db).where('id', id).first();
},
steps: async (_: any, { taskId }: { taskId: string }, { db }: Context) => {
return await steps(db).where('task_id', taskId).orderBy('order').select('*');
},
step: async (_: any, { id }: { id: string }, { db }: Context) => {
return await steps(db).where('id', id).first();
},
recentTasks: async (_: any, __: any, { db }: Context) => {
return await tasks(db)
.orderBy('last_printed_at', 'desc')
.limit(10)
.select('*');
},
frequentTasks: async (_: any, __: any, { db }: Context) => {
return await tasks(db)
.orderBy('print_count', 'desc')
.limit(10)
.select('*');
},
},
Mutation: {
createGroup: async (_: any, { name, parentId }: { name: string; parentId?: string }, { db }: Context) => {
const [id] = await groups(db).insert({
name,
parent_id: parentId ? parseInt(parentId) : null,
});
return await groups(db).where('id', id).first();
},
createTask: async (_: any, { title, groupId }: { title: string; groupId: string }, { db }: Context) => {
const [id] = await tasks(db).insert({
title,
group_id: parseInt(groupId),
print_count: 0,
});
return await tasks(db).where('id', id).first();
},
createStep: async (
_: any,
{ title, instructions, taskId, order }: { title: string; instructions: string; taskId: string; order: number },
{ db }: Context
) => {
const [id] = await steps(db).insert({
title,
instructions,
task_id: parseInt(taskId),
order,
print_count: 0,
});
return await steps(db).where('id', id).first();
},
createImage: async (
_: any,
{ stepId, originalPath, bwPath, order }: { stepId: string; originalPath: string; bwPath: string; order: number },
{ db }: Context
) => {
const [id] = await images(db).insert({
step_id: parseInt(stepId),
original_path: originalPath,
bw_path: bwPath,
order,
});
return await images(db).where('id', id).first();
},
printTask: async (_: any, { id }: { id: string }, { db }: Context) => {
const task = await tasks(db).where('id', id).first();
if (!task) throw new Error('Task not found');
await printTask(task, db);
return await tasks(db)
.where('id', id)
.update({
print_count: task.print_count + 1,
last_printed_at: new Date().toISOString(),
})
.then(() => tasks(db).where('id', id).first());
},
printStep: async (_: any, { id }: { id: string }, { db }: Context) => {
const step = await steps(db).where('id', id).first();
if (!step) throw new Error('Step not found');
await printStep(step);
return await steps(db)
.where('id', id)
.update({
print_count: step.print_count + 1,
last_printed_at: new Date().toISOString(),
})
.then(() => steps(db).where('id', id).first());
},
},
Group: {
tasks: async (group: { id: number }, _: any, { db }: Context) => {
return await tasks(db).where('group_id', group.id).select('*');
},
},
Task: {
group: async (task: { group_id: number }, _: any, { db }: Context) => {
return await groups(db).where('id', task.group_id).first();
},
steps: async (task: { id: number }, _: any, { db }: Context) => {
return await steps(db).where('task_id', task.id).orderBy('order').select('*');
},
},
Step: {
task: async (step: { task_id: number }, _: any, { db }: Context) => {
return await tasks(db).where('id', step.task_id).first();
},
images: async (step: { id: number }, _: any, { db }: Context) => {
return await images(db).where('step_id', step.id).orderBy('order').select('*');
},
},
};

View File

@ -0,0 +1,68 @@
import { gql } from 'graphql-tag';
export const typeDefs = gql`
type Group {
id: ID!
name: String!
parentId: ID
tasks: [Task!]!
createdAt: String!
updatedAt: String!
}
type Task {
id: ID!
title: String!
groupId: ID!
group: Group!
steps: [Step!]!
printCount: Int!
lastPrintedAt: String
createdAt: String!
updatedAt: String!
}
type Step {
id: ID!
title: String!
instructions: String!
taskId: ID!
task: Task!
order: Int!
images: [Image!]!
printCount: Int!
lastPrintedAt: String
createdAt: String!
updatedAt: String!
}
type Image {
id: ID!
stepId: ID!
originalPath: String!
bwPath: String!
order: Int!
createdAt: String!
updatedAt: String!
}
type Query {
groups: [Group!]!
group(id: ID!): Group
tasks(groupId: ID!): [Task!]!
task(id: ID!): Task
steps(taskId: ID!): [Step!]!
step(id: ID!): Step
recentTasks: [Task!]!
frequentTasks: [Task!]!
}
type Mutation {
createGroup(name: String!, parentId: ID): Group!
createTask(title: String!, groupId: ID!): Task!
createStep(title: String!, instructions: String!, taskId: ID!, order: Int!): Step!
createImage(stepId: ID!, originalPath: String!, bwPath: String!, order: Int!): Image!
printTask(id: ID!): Task!
printStep(id: ID!): Step!
}
`;

46
src/server/index.ts Normal file
View File

@ -0,0 +1,46 @@
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { json } from 'body-parser';
import { typeDefs } from './graphql/schema';
import { resolvers } from './graphql/resolvers';
import { createDb } from './db';
const app = express();
const port = process.env.PORT || 3000;
const db = createDb();
async function startServer() {
// Create Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
});
// Start Apollo Server
await server.start();
// Apply middleware
app.use(json());
app.use('/graphql', expressMiddleware(server, {
context: async () => ({ db }),
}));
// Start Express server
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
console.log(`GraphQL endpoint: http://localhost:${port}/graphql`);
});
}
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received. Closing database connection...');
await db.destroy();
process.exit(0);
});
startServer().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});

View File

@ -0,0 +1,16 @@
import { Knex } from 'knex';
import { Task, Step } from '../db/types';
import { TestPrinter } from './test-printer';
import { steps } from '../db';
// This will be replaced with a real printer implementation later
const printer = new TestPrinter();
export async function printTask(task: Task, db: Knex): Promise<void> {
const taskSteps = await steps(db).where('task_id', task.id).orderBy('order').select('*');
await printer.printTask(task, taskSteps);
}
export async function printStep(step: Step): Promise<void> {
await printer.printStep(step);
}

View File

@ -0,0 +1,59 @@
import fs from 'fs/promises';
import path from 'path';
import { Task, Step } from '../db/types';
import { steps } from '../db';
export class TestPrinter {
private readonly outputDir: string;
constructor() {
this.outputDir = path.join(process.cwd(), 'test-output');
this.ensureOutputDir();
}
private async ensureOutputDir() {
try {
await fs.mkdir(this.outputDir, { recursive: true });
} catch (error) {
console.error('Failed to create output directory:', error);
}
}
async getTaskSteps(task: Task): Promise<Step[]> {
return await steps().where('task_id', task.id).orderBy('order').select('*');
}
async printTask(task: Task, taskSteps: Step[]): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = path.join(this.outputDir, `task-${task.id}-${timestamp}.txt`);
const content = [
`Task: ${task.title}`,
'='.repeat(40),
'',
...taskSteps.map((step, index) => [
`Step ${index + 1}: ${step.title}`,
'-'.repeat(40),
step.instructions,
'',
]).flat(),
].join('\n');
await fs.writeFile(filename, content);
}
async printStep(step: Step): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = path.join(this.outputDir, `step-${step.id}-${timestamp}.txt`);
const content = [
`Step: ${step.title}`,
'='.repeat(40),
'',
step.instructions,
'',
].join('\n');
await fs.writeFile(filename, content);
}
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}