Grocy api
All checks were successful
App Image CI / Build app image (push) Successful in 20s
NPM Audit Check / Check NPM audit (push) Successful in -2m11s

This commit is contained in:
2025-10-14 01:18:42 -05:00
parent c9715d22f6
commit 0e812e585c
6 changed files with 188 additions and 124 deletions

View File

@@ -1,11 +1,13 @@
version: '2'
services:
nodejs-web:
container_name: nodejs-web
chore-chart:
container_name: chore-chart
build:
context: ./
dockerfile: Dockerfile
restart: "no"
ports:
- 8080:8080
volumes:
- ./config:/usr/src/app/config

86
package-lock.json generated
View File

@@ -1,24 +1,22 @@
{
"name": "nodejs-web-template",
"name": "chore-chart",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodejs-web-template",
"name": "chore-chart",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"ejs": "3.1.10",
"express": "5.1.0",
"express-session": "1.18.2",
"json5": "2.2.3",
"ky": "1.11.0"
},
"devDependencies": {
"@sindresorhus/tsconfig": "8.0.1",
"@types/express": "^5.0.3",
"@types/express-session": "^1.18.2",
"@types/node": "^24.7.0",
"forking-build-shit": "1.0.4",
"typescript": "5.9.3"
@@ -393,16 +391,6 @@
"@types/send": "*"
}
},
"node_modules/@types/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -835,46 +823,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -1305,15 +1253,6 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1390,15 +1329,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1714,18 +1644,6 @@
"node": ">=0.8.0"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",

View File

@@ -1,18 +1,18 @@
{
"name": "nodejs-web-template",
"name": "chore-chart",
"version": "0.0.1",
"description": "A nodejs web project",
"description": "Front end for Grocy chore lists",
"keywords": [
"web",
"template"
"grocy"
],
"homepage": "https://github.com/CorySanin/nodejs-web-template#readme",
"homepage": "https://git.sanin.dev/corysanin/chore-chart#readme",
"bugs": {
"url": "https://github.com/CorySanin/nodejs-web-template/issues"
"url": "https://git.sanin.dev/corysanin/chore-chart/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/CorySanin/nodejs-web-template.git"
"url": "git+https://git.sanin.dev/corysanin/chore-chart.git"
},
"license": "MIT",
"author": "Cory Sanin",
@@ -26,14 +26,12 @@
"dependencies": {
"ejs": "3.1.10",
"express": "5.1.0",
"express-session": "1.18.2",
"json5": "2.2.3",
"ky": "1.11.0"
},
"devDependencies": {
"@sindresorhus/tsconfig": "8.0.1",
"@types/express": "^5.0.3",
"@types/express-session": "^1.18.2",
"@types/node": "^24.7.0",
"forking-build-shit": "1.0.4",
"typescript": "5.9.3"

128
src/Grocy.ts Normal file
View File

@@ -0,0 +1,128 @@
import path from 'path';
import ky, { HTTPError, type KyInstance } from 'ky';
type ISODateString = string;
type Booleaish = 0 | 1;
interface GrocyConfig {
key: string;
host: string;
}
interface GrocyUser {
id: number;
username: string;
first_name: string;
last_name: string;
display_name: string;
picture_file_name: string;
row_created_timestamp: ISODateString;
}
interface GrocyChore {
chore_id: number;
chore_name: string;
last_tracked_time: ISODateString;
track_date_only: Booleaish;
next_estimated_execution_time: ISODateString;
next_execution_assigned_to_user_id: number;
is_rescheduled: Booleaish;
is_reassigned: Booleaish;
next_execution_assigned_user?: GrocyUser
}
interface GrocyChoresParameters {
query?: string[];
order?: string;
limit?: number;
}
interface ChoreExecution {
id: number;
chore_id: number;
tracked_time: ISODateString;
row_created_timestamp: ISODateString;
}
class Grocy {
private options: GrocyConfig;
private myKy: KyInstance;
constructor(options: Partial<GrocyConfig> = {}) {
if (typeof options.key !== 'string' || options.key.length <= 0) {
throw new Error('Grocy API key is required');
}
else if (typeof options.host !== 'string' || options.host.length <= 0) {
throw new Error('Grocy hostname is required');
}
this.options = options as GrocyConfig;
this.myKy = ky.extend({
headers: {
"GROCY-API-KEY": options.key
}
});
}
private resolve(destination: string, query?: URLSearchParams | undefined): string {
const queryParams = typeof query === 'object' ? query.toString() : '';
const querySuffix = queryParams.length > 0 ? `?${queryParams}` : '';
return `${path.join(this.options.host, 'api', destination)}${querySuffix}`;
}
async users() {
return await this.myKy.get<GrocyUser[]>(this.resolve('/users')).json();
}
async user(id: number) {
const users = await this.myKy.get<GrocyUser[]>(this.resolve(`/users`, new URLSearchParams({
"query[]": `id=${id}`
}))).json();
if (users && users.length > 0) {
return users[0] || null;
}
return null;
}
async chores(params: GrocyChoresParameters = { order: 'next_estimated_execution_time:asc' }) {
const searchParams = new URLSearchParams();
params?.query?.forEach(q => searchParams.append('query[]', q));
if ('order' in params) {
searchParams.append('order', params.order);
}
if ('limit' in params) {
searchParams.append('limit', String(params.limit));
}
return await this.myKy.get<GrocyChore[]>(this.resolve('/chores', searchParams)).json();
}
async chore(chore_id: number) {
return await this.myKy.get<GrocyChore>(this.resolve(`/chores/${chore_id}`)).json();
}
async doChore(chore_id: number, done_by: number) {
type ChoreExecutionError = {
error_message: string
}
try {
return await this.myKy.post<ChoreExecution>(this.resolve(`/chores/${chore_id}/execute`), {
json: {
tracked_time: new Date().toISOString(),
done_by,
skipped: false
}
}).json();
}
catch (error) {
if (!(error instanceof HTTPError)) {
throw error;
}
const errorJson = await (error as HTTPError<ChoreExecutionError>).response.json();
throw new Error(`Chore execution failed: ${errorJson?.error_message}`);
}
}
}
export default Grocy;
export { Grocy };
export type { GrocyConfig, ISODateString, GrocyUser, GrocyChore };

View File

@@ -1,10 +1,8 @@
import * as http from "http";
import crypto from 'crypto';
import express from 'express';
import session from 'express-session';
import ky from 'ky';
import express, { type Express } from 'express';
import bodyParser from 'body-parser';
import type { Express } from 'express';
import { type GrocyUser, type Grocy } from './Grocy.js';
interface WebConfig {
sessionSecret?: string;
@@ -23,39 +21,22 @@ class Web {
private _webserver: http.Server | null = null;
private app: Express | null = null;
private port: number;
private options: WebConfig;
// private options: WebConfig;
private grocy: Grocy;
constructor(options: WebConfig = {}) {
this.options = options;
constructor(options: WebConfig = {}, grocy: Grocy) {
// this.options = options;
this.port = notStupidParseInt(process.env['PORT']) || options['port'] as number || 8080;
this.grocy = grocy
}
initialize = async () => {
const options = this.options;
const sessionSecret = process.env['SESSIONSECRET'] || options.sessionSecret;
const app: Express = this.app = express();
if(!sessionSecret) {
console.error('sessionSecret is required.');
throw new Error('sessionSecret is required.');
}
app.set('trust proxy', 1);
app.set('view engine', 'ejs');
app.set('view options', { outputFunctionName: 'echo' });
app.use('/assets', express.static('assets', { maxAge: '30 days' }));
app.use(session({
name: 'sessionId',
secret: sessionSecret,
resave: true,
saveUninitialized: false,
store: undefined,
cookie: {
maxAge: notStupidParseInt(process.env['COOKIETTL']) || 1000 * 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
secure: !!options.secure
}
}));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use((_req, res, next) => {
@@ -84,11 +65,45 @@ class Web {
});
});
app.get('/ky', async (_, res) => {
res.send(await (await ky.get('https://sanin.dev')).text());
app.get('/api/chores', async (_, res) => {
const chores = await this.grocy.chores();
const users: Record<string, GrocyUser> = {};
const missing: Set<number> = new Set();
chores.forEach(chore => {
if ('next_execution_assigned_user' in chore) {
users[chore.next_execution_assigned_to_user_id] = chore.next_execution_assigned_user
}
});
chores.forEach(chore => {
if (!(chore.next_execution_assigned_to_user_id in users) && typeof chore.next_execution_assigned_to_user_id === 'number') {
missing.add(chore.next_execution_assigned_to_user_id);
}
});
(await Promise.all([...missing].map(id => this.grocy.user(id)))).forEach(user => {
if (!user) {
return;
}
users[user.id] = user;
});
chores.forEach(chore => {
if (!('next_execution_assigned_user' in chore) && chore.next_execution_assigned_to_user_id in users) {
chore.next_execution_assigned_user = users[chore.next_execution_assigned_to_user_id] as GrocyUser;
}
});
res.send({
chores
});
});
this._webserver = this.app.listen(this.port, () => console.log(`archery is running on port ${this.port}`));
app.get('/api/users/:id', async (req, res) => {
const user = await this.grocy.user(parseInt(req.params.id))
res.send({
user
});
});
this._webserver = this.app.listen(this.port, () => console.log(`chore-chart is running on port ${this.port}`));
}
close = () => {

View File

@@ -2,9 +2,11 @@ import fs from 'fs';
import path from 'path';
import JSON5 from 'json5';
import { Web, type WebConfig } from './Web.js';
import { Grocy, type GrocyConfig } from './Grocy.js';
interface compositeConfig {
web?: WebConfig
grocy?: GrocyConfig
}
async function readConfig(): Promise<compositeConfig> {
@@ -20,7 +22,8 @@ async function readConfig(): Promise<compositeConfig> {
const config: compositeConfig = await readConfig();
const web = new Web(config.web);
const grocy = new Grocy(config.grocy)
const web = new Web(config.web, grocy);
await web.initialize();
process.on('SIGTERM', () => {