generated from corysanin/nodejs-web-template
Grocy api
This commit is contained in:
@@ -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
86
package-lock.json
generated
@@ -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",
|
||||
|
14
package.json
14
package.json
@@ -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
128
src/Grocy.ts
Normal 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 };
|
73
src/Web.ts
73
src/Web.ts
@@ -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 = () => {
|
||||
|
@@ -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', () => {
|
||||
|
Reference in New Issue
Block a user