@sebbo2002/tgtg-ical 1.0.0-develop.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Sebastian Pekarek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # template
2
+
3
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE)
4
+
5
+ A small server that receives mails from TGTG, parses them and generates an iCal feed from them.
6
+
7
+
8
+ ## 🙆🏼‍♂️ Copyright and license
9
+
10
+ Copyright (c) Sebastian Pekarek under the [MIT license](LICENSE).
@@ -0,0 +1,2 @@
1
+ import{PrismaClient as r}from"@prisma/client";var o=new r,g=o;import{init as i}from"@sentry/node";import{dirname as c,resolve as n}from"path";import{fileURLToPath as l}from"url";i({dsn:process.env.SENTRY_DSN||"https://5e4630d58e5f4c778ce22140c53b3684@glitch.sebbo.net/9"});var b={baseUrl:process.env.BASE_URL||"https://tgtg-ical.sebbo.net",baseMail:process.env.BASE_MAIL||"@tgtg-ical.sebbo.net",version:process.env.npm_package_version||void 0,src(t){let e=c(l(import.meta.url));return e.endsWith("/dist")?e=n(e,"..","src"):e=n(e,".."),n(e,t||"")}};var S="\u{1F374}",s={"\u{1F354}":[/Burger/,/McDonald[‘|']?s/,/Burger King/,/Hans im Glück/,/Peter Pane/],"\u{1F96A}":[/Sandwich/,/Tank ?& ?Rast/,/T&R Raststätten/,/Autohöfe/,/Aral/,/Shell/,/Jet Tank/,/Subway/,/Caf[é|e] bonjour/,/Total Deutschland/,/Esso:? Snack ?& ?Shop/],"\u{1F355}":[/Domino‘s/,/Pizza Hut/,/L[‘|']Osteria/,/Call a Pizza/,/Smiley[‘|']s Pizza/],"\u{1F357}":[/KFC/],"\u{1F35D}":[/LaTagliatella/,/Vapiano/],"\u{1F364}":[/Nordsee/],"\u{1F969}":[/Steakhouse/,/Block House/,/Jim Block/],"\u{1F32E}":[/Enchilada/,/Besitos/],"\u{1F32D}":[/Ikea/],"\u{1F957}":[/dean ?& ?david/],"\u{1F956}":[/Bäckerei/,/Back/,/BackWerk/,/Kamps/,/Kamps Backstuben/,/Junge Die Bäckerei/,/Back-Factory/],"\u{1F968}":[/Brezel/,/Ditsch/],"\u{1F950}":[/LeCroBag/],"\u{1F36A}":[/Starbucks/],"\u{1F369}":[/Donut/,/Dunkin/],"\u{1F6D2}":[/Edeka/,/Netto/,/Rewe/,/Penny/,/Lidl/,/Kaufland/,/Aldi/,/dm/,/Rossmann/,/Globus/,/Metro/,/Norma/,/Tegut/]};function m(t){for(let e in s)if(!!s[e].map(a=>new RegExp(a,"i")).find(a=>a.test(t)))return e;return null}export{g as a,b,S as c,m as d};
2
+ //# sourceMappingURL=chunk-HTOHNK4X.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/db.ts","../src/lib/config.ts","../src/lib/emoji.ts"],"sourcesContent":["import { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();\nexport default prisma;\n","import { init } from '@sentry/node';\nimport { dirname, resolve } from 'path';\nimport { fileURLToPath } from 'url';\n\ninit({\n dsn: process.env.SENTRY_DSN || 'https://5e4630d58e5f4c778ce22140c53b3684@glitch.sebbo.net/9',\n});\n\nexport default {\n baseUrl: process.env.BASE_URL || 'https://tgtg-ical.sebbo.net',\n baseMail: process.env.BASE_MAIL || '@tgtg-ical.sebbo.net',\n version: process.env.npm_package_version || undefined,\n src(path?: string) {\n let dir = dirname(fileURLToPath(import.meta.url));\n if (dir.endsWith('/dist')) {\n dir = resolve(dir, '..', 'src');\n } else {\n dir = resolve(dir, '..');\n }\n\n return resolve(dir, path || '');\n }\n};\n","export const DEFAULT_EMOJI = '🍴';\nexport const EMOJIS: Record<string, RegExp[]> = {\n '🍔': [\n /Burger/,\n /McDonald[‘|']?s/,\n /Burger King/,\n /Hans im Glück/,\n /Peter Pane/,\n\n ],\n '🥪': [\n /Sandwich/,\n /Tank ?& ?Rast/,\n /T&R Raststätten/,\n /Autohöfe/,\n /Aral/,\n /Shell/,\n /Jet Tank/,\n /Subway/,\n /Caf[é|e] bonjour/,\n /Total Deutschland/,\n /Esso:? Snack ?& ?Shop/,\n\n ],\n '🍕': [\n /Domino‘s/,\n /Pizza Hut/,\n /L[‘|']Osteria/,\n /Call a Pizza/,\n /Smiley[‘|']s Pizza/,\n\n ],\n '🍗': [\n /KFC/,\n\n ],\n '🍝': [\n /LaTagliatella/,\n /Vapiano/,\n\n ],\n '🍤': [\n /Nordsee/\n ],\n '🥩': [\n /Steakhouse/,\n /Block House/,\n /Jim Block/,\n\n ],\n '🌮': [\n /Enchilada/,\n /Besitos/,\n\n ],\n '🌭': [\n /Ikea/,\n\n ],\n '🥗': [\n /dean ?& ?david/\n ],\n '🥖': [\n /Bäckerei/,\n /Back/,\n /BackWerk/,\n /Kamps/,\n /Kamps Backstuben/,\n /Junge Die Bäckerei/,\n /Back-Factory/,\n\n ],\n '🥨': [\n /Brezel/,\n /Ditsch/,\n\n ],\n '🥐': [\n /LeCroBag/,\n\n ],\n '🍪': [\n /Starbucks/\n ],\n '🍩': [\n /Donut/,\n /Dunkin/,\n\n ],\n '🛒': [\n /Edeka/,\n /Netto/,\n /Rewe/,\n /Penny/,\n /Lidl/,\n /Kaufland/,\n /Aldi/,\n /dm/,\n /Rossmann/,\n /Globus/,\n /Metro/,\n /Norma/,\n /Tegut/,\n\n ]\n};\n\nexport default function getEmoji(location: string): string | null {\n for (const emoji in EMOJIS) {\n const regExps = EMOJIS[emoji].map((name) => new RegExp(name, 'i'));\n const match = !!regExps.find(regExp => regExp.test(location));\n if(match) {\n return emoji;\n }\n }\n\n return null;\n}\n"],"mappings":"AAAA,OAAS,gBAAAA,MAAoB,iBAE7B,IAAMC,EAAS,IAAID,EACZE,EAAQD,ECHf,OAAS,QAAAE,MAAY,eACrB,OAAS,WAAAC,EAAS,WAAAC,MAAe,OACjC,OAAS,iBAAAC,MAAqB,MAE9BH,EAAK,CACD,IAAK,QAAQ,IAAI,YAAc,6DACnC,CAAC,EAED,IAAOI,EAAQ,CACX,QAAS,QAAQ,IAAI,UAAY,8BACjC,SAAU,QAAQ,IAAI,WAAa,uBACnC,QAAS,QAAQ,IAAI,qBAAuB,OAC5C,IAAIC,EAAe,CACf,IAAIC,EAAML,EAAQE,EAAc,YAAY,GAAG,CAAC,EAChD,OAAIG,EAAI,SAAS,OAAO,EACpBA,EAAMJ,EAAQI,EAAK,KAAM,KAAK,EAE9BA,EAAMJ,EAAQI,EAAK,IAAI,EAGpBJ,EAAQI,EAAKD,GAAQ,EAAE,CAClC,CACJ,ECtBO,IAAME,EAAgB,YAChBC,EAAmC,CAC5C,YAAM,CACF,SACA,kBACA,cACA,gBACA,YAEJ,EACA,YAAM,CACF,WACA,gBACA,kBACA,WACA,OACA,QACA,WACA,SACA,mBACA,oBACA,uBAEJ,EACA,YAAM,CACF,WACA,YACA,gBACA,eACA,oBAEJ,EACA,YAAM,CACF,KAEJ,EACA,YAAM,CACF,gBACA,SAEJ,EACA,YAAM,CACF,SACJ,EACA,YAAM,CACF,aACA,cACA,WAEJ,EACA,YAAM,CACF,YACA,SAEJ,EACA,YAAM,CACF,MAEJ,EACA,YAAM,CACF,gBACJ,EACA,YAAM,CACF,WACA,OACA,WACA,QACA,mBACA,qBACA,cAEJ,EACA,YAAM,CACF,SACA,QAEJ,EACA,YAAM,CACF,UAEJ,EACA,YAAM,CACF,WACJ,EACA,YAAM,CACF,QACA,QAEJ,EACA,YAAM,CACF,QACA,QACA,OACA,QACA,OACA,WACA,OACA,KACA,WACA,SACA,QACA,QACA,OAEJ,CACJ,EAEe,SAARC,EAA0BC,EAAiC,CAC9D,QAAWC,KAASH,EAGhB,GADc,CAAC,CADCA,EAAOG,CAAK,EAAE,IAAKC,GAAS,IAAI,OAAOA,EAAM,GAAG,CAAC,EACzC,KAAKC,GAAUA,EAAO,KAAKH,CAAQ,CAAC,EAExD,OAAOC,EAIf,OAAO,IACX","names":["PrismaClient","prisma","db_default","init","dirname","resolve","fileURLToPath","config_default","path","dir","DEFAULT_EMOJI","EMOJIS","getEmoji","location","emoji","name","regExp"]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import{a as o,b as s,d as f}from"./chunk-HTOHNK4X.js";import{startTransaction as p,captureException as w}from"@sentry/node";import{simpleParser as M}from"mailparser";import n from"moment-timezone";var m=class{static async runCleanup(){let t=await o.mail.findMany({where:{OR:[{error:null},{version:{not:s.version}}]},orderBy:{erroredAt:"asc"},take:10});for(let r of t)await this.handleMail(r);await o.user.deleteMany({where:{OR:[{lastSeenAt:{lt:n().subtract(8,"weeks").toDate()}},{lastSeenAt:{equals:o.user.fields.createdAt}}]}}),await o.event.deleteMany({where:{to:{lt:n().subtract(4,"weeks").toDate()}}}),await o.mail.deleteMany({where:{createdAt:{lt:n().subtract(2,"weeks").toDate()}}})}static async inhaleMail(t){let r=await o.mail.create({data:{raw:t}});await this.handleMail(r)}static async handleMail(t){let r=p({op:"mail",name:"parse mail"});try{let e=await this.parseMail(t.raw);e&&await this.applyParsedMail(e),await o.mail.delete({where:{id:t.id}})}catch(e){let i=w(e);await o.mail.update({where:{id:t.id},data:{error:e instanceof Error?e.stack:String(e),erroredAt:new Date,errorId:i,version:s.version}})}finally{r.finish()}}static async parseMail(t,r=s.baseMail){let e=await M(t,{skipHtmlToText:!0,skipImageLinks:!0,skipTextToHtml:!0,skipTextLinks:!0});if(!e.from.value[0].address.endsWith("toogoodtogo.com"))throw new Error("Not a TGTG email!");let i=(Array.isArray(e.to)?e.to:[e.to]).map(a=>a.value).flat().map(a=>a.address).find(a=>a.endsWith(r));if(!i){let a=e.headers.get("received"),d=new RegExp(`([\\w-]+${s.baseMail})`,"i");Array.isArray(a)&&a.forEach(u=>{let c=u.match(d);c&&(i=c[1])})}if(e.headers.get("x-pm-tag")==="consumer_order_confirm"){let a=this.parseOrderMail(e);return{to:i,...a}}if(e.headers.get("x-pm-tag")==="collection_time_changed"){let a=this.parseChangeMail(e);return{to:i,...a}}if(e.headers.get("x-pm-tag")==="consumer_order_reverted"){let a=this.parseCancellationMail(e);if(a)return{to:i,...a}}if(e.headers.get("x-pm-tag")==="invoice"){let a=this.parseInvoiceMail(e);if(a)return{to:i,...a}}if(!(!e.headers.get("x-pm-tag")&&!e.headers.get("x-pm-message-id")))throw e.headers.get("x-pm-tag")?new Error(`Unsupported email type: ${e.headers.get("x-pm-tag")}`):new Error("Not implemented!")}static async findUser(t){if(!t)throw new Error("Did not found a valid recipient!");let r=t.split("@")[0],e=await o.user.findUnique({where:{prefix:r}});if(!e)throw new Error(`User with email prefix ${r} not found!`);return e}static parseOrderMail(t){let r=t.html||"",e=[r.match(/\/order\/([^\/]+)\//),r.match(/Wir bestätigen hiermit deine Bestellung bei ([^\(\.]+)/),r.match(/<span>Du kannst deine Bestellung am (\d{1,2}\.\d{2}\.\d{2}) zwischen (\d{1,2}:\d{2}) und (\d{1,2}:\d{2}) Uhr (\w+)[^:]+: (.+).<\/span><\/div>/),r.match(/<span>Du kannst deine Bestellung zwischen (\d{1,2}\.\d{1,2}), (\d{1,2}:\d{2}) und (\d{1,2}:\d{2})[^:]+: (.+).<\/span><\/div>/),r.match(/Anzahl: (\d+)/),r.match(/Gesamtpreis: ([\d,.]+)[^\d,.]/)];if(!e[0])throw new Error("Order ID not found!");if(!e[1])throw new Error("Location name not found!");if(!e[2]&&!e[3])throw new Error("Date, time and address not found (1)!");if(!e[4])throw new Error("Amount not found!");if(!e[5])throw new Error("Price not found!");let i=e[2]?e[2][4]:"MET";i==="MEZ"&&(i="MET");let a,d;if(e[2]&&(a=n.tz(e[2][1]+" "+e[2][2],"DD.MM.YY HH:mm",i),d=n.tz(e[2][1]+" "+e[2][3],"DD.MM.YY HH:mm",i)),e[3]&&(a=n.tz(e[3][1]+" "+e[3][2],"DD.MM HH:mm",i),d=n.tz(e[3][1]+" "+e[3][3],"DD.MM HH:mm",i),a.isBefore(n())&&a.add(1,"year"),d.isBefore(a)&&d.add(1,"year")),!e[2]&&!e[3])throw new Error("Date, time and address not found (1)!");let u=parseInt(e[4][1],10);if(isNaN(u))throw new Error("Amount is not a number!");let c=parseInt(e[5][1].replace(/[.|,]/g,""));if(isNaN(c))throw new Error("Price is not a number!");return{type:"order",orderId:e[0][1].trim(),location:{name:e[1][1].trim(),address:e[2]?e[2][5].trim():e[3][4].trim()},time:{order:n(t.date),from:a,to:d},amount:u,price:c}}static parseChangeMail(t){let r=[(t.subject||"").match(/(\w+)$/),(t.html||"").match(/ am (\d{1,2}\.\d{2}\.\d{2}) zwischen (\d{1,2}:\d{2}) und (\d{1,2}:\d{2}) Uhr (\w+)+ /)];if(!r[0])throw new Error("Order ID not found in subject!");if(!r[1])throw new Error("Date / Time not found!");let e=r[1][4];e==="MEZ"&&(e="MET");let i=n.tz(r[1][1]+" "+r[1][2],"DD.MM.YY HH:mm",e),a=n.tz(r[1][1]+" "+r[1][3],"DD.MM.YY HH:mm",e);return{type:"change",orderId:r[0][1].trim(),time:{from:i,to:a}}}static parseCancellationMail(t){let e=(t.subject||"").match(/\((\w+)\)/);if(e)return{type:"cancel",orderId:e[1],cancelledAt:n(t.date)}}static parseInvoiceMail(t){let e=(t.html||"").match(/Die Rechnung für deine Bestellung (\w+)/);if(e)return{type:"invoice",orderId:e[1],invoicedAt:n(t.date)};throw new Error("Order Id not found!")}static async applyParsedMail(t){let r=await this.findUser(t.to);if(t.type==="order"){let e=await this.getLocation(t.location);await o.event.upsert({where:{orderId:t.orderId,userId:r.id},create:{orderId:t.orderId,orderedAt:t.time.order.toDate(),from:t.time.from.toDate(),to:t.time.to.toDate(),amount:t.amount,price:t.price,user:{connect:{id:r.id}},location:{connect:{id:e.id}}},update:{from:t.time.from.toDate(),to:t.time.to.toDate(),amount:t.amount,price:t.price,location:{connect:{id:e.id}}}})}else if(t.type==="change")await o.event.update({where:{orderId:t.orderId,userId:r.id},data:{from:t.time.from.toDate(),to:t.time.to.toDate()}});else if(t.type==="cancel")await o.event.update({where:{orderId:t.orderId,userId:r.id},data:{canceledAt:t.cancelledAt.toDate()}});else if(t.type==="invoice")await o.event.update({where:{orderId:t.orderId,userId:r.id},data:{invoicedAt:t.invoicedAt.toDate()}});else throw new Error("Unknown email type!")}static async getLocation(t){let r=await o.location.findFirst({where:{name:t.name,address:t.address}});if(!r){let e=f(t.name),{latitude:i,longitude:a}=await this.geocode(t.address);r=await o.location.create({data:{name:t.name,address:t.address,latitude:i,longitude:a,emoji:e}})}return r}static async geocode(t){let r=await fetch("https://nominatim.openstreetmap.org/search?format=json&limit=1&q="+encodeURIComponent(t),{headers:{"User-Agent":`tgtg-ical/${s.version} (${s.baseUrl})`,Referer:s.baseUrl}});if(!r.ok)throw new Error("Geocoding failed: "+r.statusText);await new Promise(i=>setTimeout(i,1e3));let e=await r.json();return!Array.isArray(e)||e.length===0?{latitude:null,longitude:null}:{latitude:parseFloat(e[0].lat),longitude:parseFloat(e[0].lon)}}};var h="";process.stdin.on("data",l=>{h+=l});process.stdin.on("end",()=>{m.inhaleMail(h).catch(l=>{console.error(l),process.exit(1)})});
3
+ //# sourceMappingURL=inhale-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/parser.ts","../src/bin/inhale-mail.ts"],"sourcesContent":["import { Mail, User, Location } from '@prisma/client';\nimport { startTransaction, captureException } from '@sentry/node';\nimport { ParsedMail, simpleParser } from 'mailparser';\nimport prisma from './db.js';\nimport config from './config.js';\nimport moment from 'moment-timezone';\nimport getEmoji from './emoji.js';\nimport Config from './config.js';\n\ntype AnyMail = { to: string; } & (OrderMail | ChangeMail | InvoiceMail | CancellationMail);\n\ninterface OrderMail {\n type: 'order';\n orderId: string;\n location: {\n name: string;\n address: string;\n };\n time: {\n order: moment.Moment;\n from: moment.Moment;\n to: moment.Moment;\n };\n amount: number;\n price: number;\n}\n\ninterface ChangeMail {\n type: 'change';\n orderId: string;\n time: {\n from: moment.Moment;\n to: moment.Moment;\n };\n}\n\ninterface InvoiceMail {\n type: 'invoice';\n orderId: string;\n invoicedAt: moment.Moment;\n}\n\ninterface CancellationMail {\n type: 'cancel';\n orderId: string;\n cancelledAt: moment.Moment;\n}\n\nexport default class Parser {\n public static async runCleanup(): Promise<void> {\n const mails = await prisma.mail.findMany({\n where: {\n OR: [\n { error: null },\n { version: { not: config.version } }\n ]\n },\n orderBy: {\n erroredAt: 'asc'\n },\n take: 10\n });\n\n for(const mail of mails) {\n await this.handleMail(mail);\n }\n\n // Cleanup Users\n await prisma.user.deleteMany({\n where: {\n OR: [\n {\n lastSeenAt: {\n lt: moment().subtract(8, 'weeks').toDate()\n }\n },\n {\n lastSeenAt: {\n equals: prisma.user.fields.createdAt\n }\n }\n ]\n }\n });\n\n // Cleanup Events\n await prisma.event.deleteMany({\n where: {\n to: {\n lt: moment().subtract(4, 'weeks').toDate()\n }\n }\n });\n\n // Cleanup Mails\n await prisma.mail.deleteMany({\n where: {\n createdAt: {\n lt: moment().subtract(2, 'weeks').toDate()\n }\n }\n });\n }\n\n public static async inhaleMail (email: string): Promise<void> {\n const mail = await prisma.mail.create({\n data: {\n raw: email\n }\n });\n\n await this.handleMail(mail);\n }\n\n public static async handleMail(mail: Mail) {\n // @todo Sentry Transaction (https://docs.sentry.io/platforms/node/#verify)\n const transaction = startTransaction({\n op: 'mail',\n name: 'parse mail'\n });\n\n try {\n const parsed = await this.parseMail(mail.raw);\n if(parsed) {\n await this.applyParsedMail(parsed);\n }\n\n await prisma.mail.delete({\n where: {\n id: mail.id\n }\n });\n }\n catch(error) {\n const errorId = captureException(error);\n await prisma.mail.update({\n where: {\n id: mail.id\n },\n data: {\n error: error instanceof Error ? error.stack : String(error),\n erroredAt: new Date(),\n errorId,\n version: config.version\n }\n });\n }\n finally {\n transaction.finish();\n }\n }\n\n public static async parseMail(mail: string, baseMailPostfix = config.baseMail): Promise<AnyMail|undefined> {\n // parse email\n const email = await simpleParser(mail, {\n skipHtmlToText: true,\n skipImageLinks: true,\n skipTextToHtml: true,\n skipTextLinks: true\n });\n\n if(!email.from.value[0].address.endsWith('toogoodtogo.com')) {\n throw new Error('Not a TGTG email!');\n }\n\n let to: string | undefined = (Array.isArray(email.to) ? email.to : [email.to])\n .map(to => to.value)\n .flat()\n .map(address => address.address)\n .find(address => address.endsWith(baseMailPostfix));\n\n if(!to) {\n const received = email.headers.get('received');\n const regexp = new RegExp(`([\\\\w-]+${Config.baseMail})`, 'i');;\n if(Array.isArray(received)) {\n received.forEach(r => {\n const match = r.match(regexp);\n if(match) {\n to = match[1];\n }\n });\n }\n }\n\n // Order confirmation\n if(email.headers.get('x-pm-tag') === 'consumer_order_confirm') {\n const order = this.parseOrderMail(email);\n return {\n to,\n ...order\n };\n }\n\n // Time Changed\n if(email.headers.get('x-pm-tag') === 'collection_time_changed') {\n const change = this.parseChangeMail(email);\n return {\n to,\n ...change\n };\n }\n\n // Cancellation\n if(email.headers.get('x-pm-tag') === 'consumer_order_reverted') {\n const cancellation = this.parseCancellationMail(email);\n if(cancellation) {\n return {\n to,\n ...cancellation\n };\n }\n }\n\n // is invoice?\n if(email.headers.get('x-pm-tag') === 'invoice') {\n const invoice = this.parseInvoiceMail(email);\n if(invoice) {\n return {\n to,\n ...invoice\n };\n }\n }\n\n // Non-transactional email\n if(!email.headers.get('x-pm-tag') && !email.headers.get('x-pm-message-id')) {\n return undefined;\n }\n\n // Unsupported email\n if(email.headers.get('x-pm-tag')) {\n throw new Error(`Unsupported email type: ${email.headers.get('x-pm-tag')}`);\n }\n\n throw new Error('Not implemented!');\n }\n\n private static async findUser(to: string): Promise<User> {\n if(!to) {\n throw new Error('Did not found a valid recipient!');\n }\n\n const prefix = to.split('@')[0];\n const user = await prisma.user.findUnique({\n where: { prefix }\n });\n if(!user) {\n throw new Error(`User with email prefix ${prefix} not found!`);\n }\n\n return user;\n }\n\n private static parseOrderMail(email: ParsedMail): OrderMail {\n const html = email.html || '';\n const matches = [\n html.match(/\\/order\\/([^\\/]+)\\//),\n html.match(/Wir bestätigen hiermit deine Bestellung bei ([^\\(\\.]+)/),\n html.match(/<span>Du kannst deine Bestellung am (\\d{1,2}\\.\\d{2}\\.\\d{2}) zwischen (\\d{1,2}:\\d{2}) und (\\d{1,2}:\\d{2}) Uhr (\\w+)[^:]+: (.+).<\\/span><\\/div>/),\n html.match(/<span>Du kannst deine Bestellung zwischen (\\d{1,2}\\.\\d{1,2}), (\\d{1,2}:\\d{2}) und (\\d{1,2}:\\d{2})[^:]+: (.+).<\\/span><\\/div>/),\n html.match(/Anzahl: (\\d+)/),\n html.match(/Gesamtpreis: ([\\d,.]+)[^\\d,.]/)\n ];\n if(!matches[0]) {\n throw new Error('Order ID not found!');\n }\n if(!matches[1]) {\n throw new Error('Location name not found!');\n }\n if(!matches[2] && !matches[3]) {\n throw new Error('Date, time and address not found (1)!');\n }\n if(!matches[4]) {\n throw new Error('Amount not found!');\n }\n if(!matches[5]) {\n throw new Error('Price not found!');\n }\n\n let timezone = matches[2] ? matches[2][4] : 'MET';\n if(timezone === 'MEZ') {\n timezone = 'MET';\n }\n\n let from: moment.Moment | undefined;\n let to: moment.Moment | undefined;\n\n if(matches[2]) {\n from = moment.tz(matches[2][1] + ' ' + matches[2][2], 'DD.MM.YY HH:mm', timezone);\n to = moment.tz(matches[2][1] + ' ' + matches[2][3], 'DD.MM.YY HH:mm', timezone);\n }\n if(matches[3]) {\n from = moment.tz(matches[3][1] + ' ' + matches[3][2], 'DD.MM HH:mm', timezone);\n to = moment.tz(matches[3][1] + ' ' + matches[3][3], 'DD.MM HH:mm', timezone);\n\n if(from.isBefore(moment())) {\n from.add(1, 'year');\n }\n if(to.isBefore(from)) {\n to.add(1, 'year');\n }\n }\n\n if(!matches[2] && !matches[3]) {\n throw new Error('Date, time and address not found (1)!');\n }\n\n const amount = parseInt(matches[4][1], 10);\n if(isNaN(amount)) {\n throw new Error('Amount is not a number!');\n }\n\n const price = parseInt(matches[5][1].replace(/[.|,]/g, ''));\n if(isNaN(price)) {\n throw new Error('Price is not a number!');\n }\n\n return {\n type: 'order',\n orderId: matches[0][1].trim(),\n location: {\n name: matches[1][1].trim(),\n address: matches[2] ? matches[2][5].trim() : matches[3][4].trim()\n },\n time: {\n order: moment(email.date),\n from,\n to\n },\n amount,\n price\n };\n }\n\n private static parseChangeMail(email: ParsedMail): ChangeMail {\n const matches = [\n (email.subject || '').match(/(\\w+)$/),\n (email.html || '').match(/ am (\\d{1,2}\\.\\d{2}\\.\\d{2}) zwischen (\\d{1,2}:\\d{2}) und (\\d{1,2}:\\d{2}) Uhr (\\w+)+ /)\n ];\n if(!matches[0]) {\n throw new Error('Order ID not found in subject!');\n }\n if(!matches[1]) {\n throw new Error('Date / Time not found!');\n }\n\n let timezone = matches[1][4];\n if(timezone === 'MEZ') {\n timezone = 'MET';\n }\n\n const from = moment.tz(matches[1][1] + ' ' + matches[1][2], 'DD.MM.YY HH:mm', timezone);\n const to = moment.tz(matches[1][1] + ' ' + matches[1][3], 'DD.MM.YY HH:mm', timezone);\n\n return {\n type: 'change',\n orderId: matches[0][1].trim(),\n time: {\n from,\n to\n }\n };\n }\n\n private static parseCancellationMail(email: ParsedMail): CancellationMail | undefined {\n const subject = email.subject || '';\n const match = subject.match(/\\((\\w+)\\)/);\n if(match) {\n return {\n type: 'cancel',\n orderId: match[1],\n cancelledAt: moment(email.date)\n };\n }\n }\n\n private static parseInvoiceMail(email: ParsedMail): InvoiceMail {\n const html = email.html || '';\n const match = html.match(/Die Rechnung für deine Bestellung (\\w+)/);\n if(match) {\n return {\n type: 'invoice',\n orderId: match[1],\n invoicedAt: moment(email.date)\n };\n }\n\n throw new Error('Order Id not found!');\n }\n\n private static async applyParsedMail(email: AnyMail): Promise<void> {\n const user = await this.findUser(email.to);\n\n if(email.type === 'order') {\n const location = await this.getLocation(email.location);\n\n await prisma.event.upsert({\n where: {\n orderId: email.orderId,\n userId: user.id\n },\n create: {\n orderId: email.orderId,\n orderedAt: email.time.order.toDate(),\n from: email.time.from.toDate(),\n to: email.time.to.toDate(),\n amount: email.amount,\n price: email.price,\n user: {\n connect: {\n id: user.id\n }\n },\n location: {\n connect: {\n id: location.id\n }\n }\n },\n update: {\n from: email.time.from.toDate(),\n to: email.time.to.toDate(),\n amount: email.amount,\n price: email.price,\n location: {\n connect: {\n id: location.id\n }\n }\n }\n });\n }\n else if(email.type === 'change') {\n await prisma.event.update({\n where: {\n orderId: email.orderId,\n userId: user.id\n },\n data: {\n from: email.time.from.toDate(),\n to: email.time.to.toDate()\n }\n });\n }\n else if (email.type === 'cancel') {\n await prisma.event.update({\n where: {\n orderId: email.orderId,\n userId: user.id\n },\n data: {\n canceledAt: email.cancelledAt.toDate()\n }\n });\n }\n else if (email.type === 'invoice') {\n await prisma.event.update({\n where: {\n orderId: email.orderId,\n userId: user.id\n },\n data: {\n invoicedAt: email.invoicedAt.toDate()\n }\n });\n }\n else {\n throw new Error('Unknown email type!');\n }\n }\n\n private static async getLocation(input: OrderMail['location']): Promise<Location> {\n let location = await prisma.location.findFirst({\n where: {\n name: input.name,\n address: input.address\n }\n });\n if(!location) {\n const emoji = getEmoji(input.name);\n const { latitude, longitude } = await this.geocode(input.address);\n location = await prisma.location.create({\n data: {\n name: input.name,\n address: input.address,\n latitude,\n longitude,\n emoji\n }\n });\n }\n\n return location;\n }\n\n public static async geocode(address: string): Promise<{ latitude: number, longitude: number } | { latitude: null, longitude: null }> {\n const response = await fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(address), {\n headers: {\n 'User-Agent': `tgtg-ical/${config.version} (${config.baseUrl})`,\n 'Referer': config.baseUrl\n }\n });\n if(!response.ok) {\n throw new Error('Geocoding failed: ' + response.statusText);\n }\n\n // super simple way to ensure a maximum of 1 request per second in cronjobs\n await new Promise(resolve => setTimeout(resolve, 1000));\n\n const data = await response.json();\n if(!Array.isArray(data) || data.length === 0) {\n return {\n latitude: null,\n longitude: null\n };\n }\n\n return {\n latitude: parseFloat(data[0].lat),\n longitude: parseFloat(data[0].lon)\n };\n }\n}\n","#!/usr/bin/env node\n'use strict';\n\n/* istanbul ignore file */\nimport Parser from '../lib/parser.js';\n\nlet buffer = '';\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk;\n});\nprocess.stdin.on('end', () => {\n Parser.inhaleMail(buffer)\n .catch(error => {\n console.error(error);\n process.exit(1);\n });\n});\n"],"mappings":"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","names":["startTransaction","captureException","simpleParser","moment","Parser","mails","db_default","config_default","mail","moment","email","transaction","startTransaction","parsed","error","errorId","captureException","baseMailPostfix","simpleParser","to","address","received","regexp","r","match","order","change","cancellation","invoice","prefix","user","html","matches","timezone","from","amount","price","location","input","emoji","getEmoji","latitude","longitude","response","resolve","data","buffer","chunk","Parser","error"]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/start.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import{a as i,b as o,c as d}from"./chunk-HTOHNK4X.js";import p from"express";import y from"cookie-parser";import{randomUUID as h}from"crypto";import{generateName as f,generateNameWithNumber as g}from"@criblinc/docker-names";import{ICalAlarmType as w,ICalCalendar as E,ICalEventStatus as l}from"ical-generator";import I from"moment-timezone";import{readFile as U}from"fs/promises";var s=class{static async createUser(){let e;for(let t=0;;t++){let r=this.generatePrefix(t);try{if(e=await i.user.create({data:{prefix:r}}),e)break}catch(a){if(a.code==="P2002")continue;throw a}}if(!e)throw new Error("User not created");return e}static generatePrefix(e=0){return e>100?h():e<10?f():g()}static async getUser(e){let t=await i.user.findUniqueOrThrow({where:{id:e}});return this.updateUserLastSeen(t.id),t}static updateUserLastSeen(e){i.user.update({where:{id:e},data:{lastSeenAt:new Date}}).catch(t=>{console.log(t)})}static async generateUserPage(e){let[t,r]=await Promise.all([this.getUser(e),U(o.src("templates/user.html"),"utf-8")]);return r.replace(/\${CALENDAR_URL}/g,`${o.baseUrl}/${t.id}/calendar.ical`).replace(/\${EMAIL_ADDRESS}/g,`${t.prefix}${o.baseMail}`)}static async generateCalendar(e){let t=await i.user.findUniqueOrThrow({where:{id:e},select:{id:!0,event:{select:{id:!0,orderId:!0,from:!0,to:!0,amount:!0,price:!0,location:{select:{name:!0,address:!0,emoji:!0,latitude:!0,longitude:!0}},createdAt:!0,canceledAt:!0}}}}),r=new E({name:"TGTG",ttl:60*60,events:t.event.map(a=>{let m=new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(a.price/100),u=l.CONFIRMED;return a.canceledAt&&(u=l.CANCELLED),{id:a.id,start:a.from,end:a.to,timestamp:a.createdAt,summary:`${a.location.emoji||d} ${a.location.name}`,description:`${a.amount}x
3
+ ${m}`,url:`https://share.toogoodtogo.com/receipts/details/${a.orderId}`,status:u,created:a.createdAt,location:{title:a.location.name,address:a.location.address,geo:a.location.latitude&&a.location.longitude?{lat:a.location.latitude,lon:a.location.longitude}:void 0},alarms:[{type:w.display,trigger:600}]}})});return this.updateUserLastSeen(e),r.toString()}static async isHealthy(){let e=await i.mail.count({where:{createdAt:{gte:I().subtract(30,"minutes").toDate()}}});if(e>0)throw new Error(`There are ${e} unahandled mails in the queue!`)}};import{Prisma as C}from"@prisma/client";var n=class c{static run(){new c}app;server;constructor(){this.app=p(),this.app.use(y()),this.setupRoutes(),this.server=this.app.listen(process.env.PORT||8080),process.on("SIGINT",()=>this.stop()),process.on("SIGTERM",()=>this.stop())}setupRoutes(){this.app.get("/ping",(e,t)=>{t.send("pong")}),this.app.get("/",(e,t)=>{if("userId"in e.cookies&&e.cookies.userId){t.redirect("/"+e.cookies.userId);return}s.createUser().then(r=>{t.cookie("userId",r.id),t.redirect("/"+r.id)}).catch(r=>this.handleError(r,t))}),this.app.get("/_health",(e,t)=>{s.isHealthy().then(()=>t.sendStatus(201)).catch(r=>this.handleError(r,t))}),this.app.use(p.static(o.src("./assets"))),this.app.get("/:userId",(e,t)=>{t.format({"text/html":()=>{s.generateUserPage(e.params.userId).then(r=>{t.cookie("userId",e.params.userId),t.send(r)}).catch(r=>this.handleError(r,t))},"application/json":()=>{s.getUser(e.params.userId).then(r=>t.send(r)).catch(r=>this.handleError(r,t))}})}),this.app.get("/:userId/calendar.ical",(e,t)=>{s.generateCalendar(e.params.userId).then(r=>{t.set("Content-Type","text/calendar"),t.send(r)}).catch(r=>this.handleError(r,t))})}handleError(e,t){if(e instanceof C.PrismaClientKnownRequestError&&e.code==="P2025"){t.sendStatus(404);return}console.log(e),t.sendStatus(500)}async stop(){await new Promise(e=>this.server.close(e)),await i.$disconnect(),process.exit()}};n.run();
4
+ //# sourceMappingURL=start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bin/start.ts","../src/lib/server.ts"],"sourcesContent":["#!/usr/bin/env node\n'use strict';\n\nimport express, { Express, Response } from 'express';\nimport cookieParser from 'cookie-parser';\nimport {Server} from 'http';\n\nimport prisma from '../lib/db.js';\nimport ServerLib from '../lib/server.js';\nimport { Prisma } from '@prisma/client';\nimport Config from '../lib/config';\n\n\nclass AppServer {\n static run() {\n new AppServer();\n }\n\n private app: Express;\n private server: Server;\n\n constructor() {\n this.app = express();\n this.app.use(cookieParser());\n\n this.setupRoutes();\n this.server = this.app.listen(process.env.PORT || 8080);\n\n process.on('SIGINT', () => this.stop());\n process.on('SIGTERM', () => this.stop());\n }\n\n setupRoutes() {\n this.app.get('/ping', (req, res) => {\n res.send('pong');\n });\n\n this.app.get('/', (req, res) => {\n if('userId' in req.cookies && req.cookies.userId) {\n res.redirect('/' + req.cookies.userId);\n return;\n }\n\n ServerLib.createUser()\n .then(user => {\n res.cookie('userId', user.id);\n res.redirect('/' + user.id);\n })\n .catch(error => this.handleError(error, res));\n });\n\n this.app.get('/_health', (req, res) => {\n ServerLib.isHealthy()\n .then(() => res.sendStatus(201))\n .catch(error => this.handleError(error, res));\n });\n\n this.app.use(express.static(Config.src('./assets')));\n\n this.app.get('/:userId', (req, res) => {\n res.format({\n 'text/html': () => {\n ServerLib.generateUserPage(req.params.userId)\n .then(html => {\n res.cookie('userId', req.params.userId);\n res.send(html);\n })\n .catch(error => this.handleError(error, res));\n },\n 'application/json': () => {\n ServerLib.getUser(req.params.userId)\n .then(json => res.send(json))\n .catch(error => this.handleError(error, res));\n }\n });\n });\n\n this.app.get('/:userId/calendar.ical', (req, res) => {\n ServerLib.generateCalendar(req.params.userId)\n .then(ical => {\n res.set('Content-Type', 'text/calendar');\n res.send(ical);\n })\n .catch(error => this.handleError(error, res));\n });\n }\n\n handleError(error: Prisma.PrismaClientKnownRequestError | unknown, res: Response) {\n if(error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {\n res.sendStatus(404);\n return;\n }\n\n console.log(error);\n res.sendStatus(500);\n }\n\n async stop() {\n await new Promise(cb => this.server.close(cb));\n await prisma.$disconnect();\n\n process.exit();\n }\n}\n\nAppServer.run();\n","import prisma from './db.js';\nimport { randomUUID } from 'node:crypto';\nimport { generateName, generateNameWithNumber } from '@criblinc/docker-names';\nimport { User } from '@prisma/client';\nimport { ICalAlarmType, ICalCalendar, ICalEventStatus } from 'ical-generator';\nimport { DEFAULT_EMOJI } from './emoji.js';\nimport moment from 'moment-timezone';\nimport { readFile } from 'fs/promises';\nimport Config from './config.js';\n\nexport default class ServerLib {\n static async createUser() {\n let user: User | undefined;\n for (let c = 0; true; c++) {\n const prefix = this.generatePrefix(c);\n try {\n user = await prisma.user.create({\n data: {\n prefix\n }\n });\n if(user) {\n break;\n }\n }\n catch(error) {\n if(error.code === 'P2002') {\n continue;\n }\n\n throw error;\n }\n }\n if(!user) {\n throw new Error('User not created');\n }\n\n return user;\n }\n\n static generatePrefix(c = 0) {\n if(c > 100) {\n return randomUUID();\n }\n\n if(c < 10) {\n return generateName();\n }\n\n return generateNameWithNumber();\n }\n\n static async getUser(userId: string) {\n const user = await prisma.user.findUniqueOrThrow({\n where: {\n id: userId\n }\n });\n\n this.updateUserLastSeen(user.id);\n return user;\n }\n\n static updateUserLastSeen(userId: string) {\n prisma.user.update({\n where: { id: userId },\n data: { lastSeenAt: new Date() }\n }).catch(error => {\n console.log(error);\n });\n }\n\n static async generateUserPage(userId: string) {\n const [user, html] = await Promise.all([\n this.getUser(userId),\n readFile(Config.src('templates/user.html'), 'utf-8')\n ]);\n\n return html\n .replace(/\\${CALENDAR_URL}/g, `${Config.baseUrl}/${user.id}/calendar.ical`)\n .replace(/\\${EMAIL_ADDRESS}/g, `${user.prefix}${Config.baseMail}`);\n }\n\n static async generateCalendar(userId: string) {\n const user = await prisma.user.findUniqueOrThrow({\n where: {\n id: userId\n },\n select: {\n id: true,\n event: {\n select: {\n id: true,\n orderId: true,\n from: true,\n to: true,\n amount: true,\n price: true,\n location: {\n select: {\n name: true,\n address: true,\n emoji: true,\n latitude: true,\n longitude: true\n }\n },\n createdAt: true,\n canceledAt: true\n }\n }\n }\n });\n\n const cal = new ICalCalendar({\n name: 'TGTG',\n ttl: 60 * 60,\n events: user.event.map(event => {\n const price = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })\n .format(event.price / 100);\n\n let status: ICalEventStatus = ICalEventStatus.CONFIRMED;\n if(event.canceledAt) {\n status = ICalEventStatus.CANCELLED;\n }\n\n return {\n id: event.id,\n start: event.from,\n end: event.to,\n timestamp: event.createdAt,\n summary: `${event.location.emoji || DEFAULT_EMOJI} ${event.location.name}`,\n description: `${event.amount}x\\n${price}`,\n url: `https://share.toogoodtogo.com/receipts/details/${event.orderId}`,\n status,\n created: event.createdAt,\n location: {\n title: event.location.name,\n address: event.location.address,\n geo: event.location.latitude && event.location.longitude ? {\n lat: event.location.latitude,\n lon: event.location.longitude\n } : undefined\n },\n alarms: [\n {type: ICalAlarmType.display, trigger: 600},\n ]\n };\n })\n });\n\n this.updateUserLastSeen(userId);\n return cal.toString();\n }\n\n static async isHealthy() {\n const c = await prisma.mail.count({\n where: {\n createdAt: {\n gte: moment().subtract(30, 'minutes').toDate()\n }\n }\n });\n if(c > 0) {\n throw new Error(`There are ${c} unahandled mails in the queue!`);\n }\n }\n}\n"],"mappings":"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","names":["express","cookieParser","randomUUID","generateName","generateNameWithNumber","ICalAlarmType","ICalCalendar","ICalEventStatus","moment","readFile","ServerLib","user","c","prefix","db_default","error","randomUUID","generateName","generateNameWithNumber","userId","html","readFile","config_default","cal","ICalCalendar","event","price","status","ICalEventStatus","DEFAULT_EMOJI","ICalAlarmType","moment","Prisma","AppServer","_AppServer","express","cookieParser","req","res","ServerLib","user","error","config_default","html","json","ical","Prisma","cb","db_default"]}
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "author": "Sebastian Pekarek <mail@sebbo.net>",
3
+ "bin": {
4
+ "tgtg-ical-server": "./dist/start.js",
5
+ "tgtg-ical-inhale-mail": "./dist/bin/inhale-mail.js"
6
+ },
7
+ "bugs": {
8
+ "url": "https://github.com/sebbo2002/tgtg-ical/issues"
9
+ },
10
+ "dependencies": {
11
+ "@criblinc/docker-names": "^1.2.1",
12
+ "@prisma/client": "^5.1.1",
13
+ "@sentry/node": "^7.64.0",
14
+ "cookie-parser": "^1.4.6",
15
+ "express": "^4.18.2",
16
+ "ical-generator": "^5.0.0",
17
+ "mailparser": "^3.6.5",
18
+ "moment-timezone": "^0.5.43"
19
+ },
20
+ "description": "A small server that receives mails from TGTG, parses them and generates an iCal feed from them.",
21
+ "devDependencies": {
22
+ "@qiwi/semantic-release-gh-pages-plugin": "^5.2.8",
23
+ "@sebbo2002/semantic-release-docker": "^4.0.0",
24
+ "@semantic-release/changelog": "^6.0.3",
25
+ "@semantic-release/exec": "^6.0.3",
26
+ "@semantic-release/git": "^10.0.1",
27
+ "@semantic-release/npm": "^10.0.4",
28
+ "@types/express": "^4.17.17",
29
+ "@types/mailparser": "^3.4.0",
30
+ "@types/mocha": "^10.0.1",
31
+ "@types/node": "^20.5.0",
32
+ "@typescript-eslint/eslint-plugin": "^6.4.0",
33
+ "@typescript-eslint/parser": "^6.4.0",
34
+ "c8": "^8.0.1",
35
+ "eslint": "^8.47.0",
36
+ "eslint-plugin-jsonc": "^2.9.0",
37
+ "esm": "^3.2.25",
38
+ "license-checker": "^25.0.1",
39
+ "mocha": "^10.2.0",
40
+ "mochawesome": "^7.1.3",
41
+ "prisma": "^5.1.1",
42
+ "semantic-release-license": "^1.0.3",
43
+ "source-map-support": "^0.5.21",
44
+ "ts-node": "^10.9.1",
45
+ "tsup": "^7.2.0",
46
+ "typescript": "^5.1.6"
47
+ },
48
+ "engines": {
49
+ "node": ">=18.0.0"
50
+ },
51
+ "files": [
52
+ "/dist",
53
+ "/src/assets",
54
+ "/src/prisma",
55
+ "/src/templates"
56
+ ],
57
+ "homepage": "https://github.com/sebbo2002/tgtg-ical#readme",
58
+ "license": "MIT",
59
+ "name": "@sebbo2002/tgtg-ical",
60
+ "prisma": {
61
+ "schema": "./src/prisma/schema.prisma"
62
+ },
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/sebbo2002/tgtg-ical.git"
66
+ },
67
+ "scripts": {
68
+ "build": "tsup",
69
+ "build-all": "./.github/workflows/build.sh",
70
+ "coverage": "c8 mocha",
71
+ "develop": "ts-node ./src/bin/start.ts",
72
+ "license-check": "license-checker --production --summary",
73
+ "lint": "eslint . --ext .ts,.json",
74
+ "start": "node ./dist/bin/start.js",
75
+ "test": "mocha"
76
+ },
77
+ "type": "module",
78
+ "version": "1.0.0-develop.0"
79
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns:xlink="http://www.w3.org/1999/xlink" width="24" xmlns="http://www.w3.org/2000/svg" height="24" id="screenshot-2a0b805d-de78-8023-8002-f1f317ab743a" viewBox="0 0 24 24" style="-webkit-print-color-adjust: exact;" fill="none" version="1.1"><g id="shape-2a0b805d-de78-8023-8002-f1f317ab743a"><defs><clipPath class="frame-clip-def frame-clip" id="frame-clip-2a0b805d-de78-8023-8002-f1f317ab743a-rumext-id-1"><rect rx="0" ry="0" x="0" y="0" width="24" height="24" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g clip-path="url(#frame-clip-2a0b805d-de78-8023-8002-f1f317ab743a-rumext-id-1)"><clipPath class="frame-clip-def frame-clip" id="frame-clip-2a0b805d-de78-8023-8002-f1f317ab743a-rumext-id-1"><rect rx="0" ry="0" x="0" y="0" width="24" height="24" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath><g class="fills" id="fills-2a0b805d-de78-8023-8002-f1f317ab743a"><rect rx="0" ry="0" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="24" height="24" class="frame-background"/></g><g class="frame-children"><g id="shape-2a0b805d-de78-8023-8002-f1f38567a273" width="24" class="icon icon-tabler icon-tabler-paper-bag" height="24" rx="0" ry="0" style="fill: rgb(0, 0, 0);"><g id="shape-2a0b805d-de78-8023-8002-f1f38567a275"><g class="fills" id="fills-2a0b805d-de78-8023-8002-f1f38567a275"><path fill="none" stroke="none" stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M-2.000,-2.000L26.000,-2.000L26.000,26.000L-2.000,26.000Z" style="fill: none;"/></g><g id="strokes-2a0b805d-de78-8023-8002-f1f38567a275" class="strokes"><g class="stroke-shape"><path stroke="none" stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M-2.000,-2.000L26.000,-2.000L26.000,26.000L-2.000,26.000Z" style="fill: none; stroke-width: 2;"/></g></g></g><g id="shape-2a0b805d-de78-8023-8002-f1f38567a276"><g class="fills" id="fills-2a0b805d-de78-8023-8002-f1f38567a276"><path fill="none" stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M7.333,1.500L16.667,1.500C17.955,1.500,19.000,2.545,19.000,3.833L19.000,5.957C19.000,6.862,19.211,7.755,19.616,8.565L20.717,10.768C21.122,11.578,21.333,12.471,21.333,13.377L21.333,20.167C21.333,21.455,20.289,22.500,19.000,22.500L5.000,22.500C3.711,22.500,2.667,21.455,2.667,20.167L2.667,13.377C2.667,12.471,2.878,11.578,3.283,10.768L5.000,7.333L5.000,3.833C5.000,2.545,6.045,1.500,7.333,1.500ZZ" style="fill: none;"/></g><g id="strokes-2a0b805d-de78-8023-8002-f1f38567a276" class="strokes"><g class="stroke-shape"><path stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M7.333,1.500L16.667,1.500C17.955,1.500,19.000,2.545,19.000,3.833L19.000,5.957C19.000,6.862,19.211,7.755,19.616,8.565L20.717,10.768C21.122,11.578,21.333,12.471,21.333,13.377L21.333,20.167C21.333,21.455,20.289,22.500,19.000,22.500L5.000,22.500C3.711,22.500,2.667,21.455,2.667,20.167L2.667,13.377C2.667,12.471,2.878,11.578,3.283,10.768L5.000,7.333L5.000,3.833C5.000,2.545,6.045,1.500,7.333,1.500ZZ" style="fill: none; stroke-width: 2; stroke: rgb(75, 129, 80); stroke-opacity: 1;"/></g></g></g><g id="shape-2a0b805d-de78-8023-8002-f1f38567a277"><g class="fills" id="fills-2a0b805d-de78-8023-8002-f1f38567a277"><path fill="none" stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M14.333,15.500M12.000,15.500C12.000,16.789,13.045,17.833,14.333,17.833C15.622,17.833,16.667,16.789,16.667,15.500C16.667,14.211,15.622,13.167,14.333,13.167C13.045,13.167,12.000,14.211,12.000,15.500Z" style="fill: none;"/></g><g id="strokes-2a0b805d-de78-8023-8002-f1f38567a277" class="strokes"><g class="stroke-shape"><path stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M14.333,15.500M12.000,15.500C12.000,16.789,13.045,17.833,14.333,17.833C15.622,17.833,16.667,16.789,16.667,15.500C16.667,14.211,15.622,13.167,14.333,13.167C13.045,13.167,12.000,14.211,12.000,15.500Z" style="fill: none; stroke-width: 2; stroke: rgb(75, 129, 80);"/></g></g></g><g id="shape-2a0b805d-de78-8023-8002-f1f38567a278"><g class="fills" id="fills-2a0b805d-de78-8023-8002-f1f38567a278"><path fill="none" stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M5.000,22.500C6.289,22.500,7.333,21.455,7.333,20.167L7.333,13.377C7.333,12.471,7.122,11.578,6.717,10.768L5.000,7.333" style="fill: none;"/></g><g id="strokes-2a0b805d-de78-8023-8002-f1f38567a278" class="strokes"><g class="stroke-shape"><path stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M5.000,22.500C6.289,22.500,7.333,21.455,7.333,20.167L7.333,13.377C7.333,12.471,7.122,11.578,6.717,10.768L5.000,7.333" style="fill: none; stroke-width: 2; stroke: rgb(75, 129, 80); stroke-opacity: 1;"/></g></g></g><g id="shape-2a0b805d-de78-8023-8002-f1f38567a279"><g class="fills" id="fills-2a0b805d-de78-8023-8002-f1f38567a279"><path fill="none" stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M10.833,6.167L13.167,6.167" style="fill: none;"/></g><g id="strokes-2a0b805d-de78-8023-8002-f1f38567a279" class="strokes"><g class="stroke-shape"><path stroke-linecap="round" stroke-linejoin="round" rx="0" ry="0" d="M10.833,6.167L13.167,6.167" style="fill: none; stroke-width: 2; stroke: rgb(75, 129, 80);"/></g></g></g></g></g></g></g></svg>
Binary file
@@ -0,0 +1,165 @@
1
+ :root {
2
+ --color-primary: #4B8150;
3
+ --color-primary-rgb: 75, 129, 80;
4
+ --color-primary-dark: #38613c;
5
+ --color-primary-dark-rgb: 56, 97, 60;
6
+ --color-white: #e7e4e7;
7
+ --color-white-rgb: 231, 228, 231;
8
+ --color-text: #474847;
9
+ --color-text-rgb: 71, 72, 71;
10
+ }
11
+ ::selection {
12
+ color: var(--color-white);
13
+ background-color: var(--color-primary-dark);
14
+ }
15
+
16
+ a:link,
17
+ a:visited {
18
+ color: inherit;
19
+ }
20
+
21
+ html {
22
+ box-sizing: border-box;
23
+ font-size: 16px;
24
+ font-family: Seravek, 'Gill Sans Nova', Ubuntu, Calibri, 'DejaVu Sans', source-sans-pro, sans-serif;
25
+ min-height: 100vh;
26
+ }
27
+
28
+ *, *:before, *:after {
29
+ box-sizing: inherit;
30
+ }
31
+
32
+ body, h1, h2, h3, h4, h5, h6, p, ol, ul {
33
+ margin: 0;
34
+ padding: 0;
35
+ font-weight: normal;
36
+ }
37
+
38
+ ol, ul {
39
+ list-style: none;
40
+ }
41
+
42
+ img {
43
+ max-width: 100%;
44
+ height: auto;
45
+ }
46
+
47
+ body {
48
+ background: var(--color-white);
49
+ min-height: 100vh;
50
+ color: var(--color-text);
51
+ }
52
+
53
+ .app {
54
+ display: flex;
55
+ flex-direction: column;
56
+ width: 100%;
57
+ max-width: 32em;
58
+ min-height: 100vh;
59
+ margin: 0 auto;
60
+ padding: 1em;
61
+ justify-content: center;
62
+ align-content: stretch;
63
+ }
64
+
65
+ .header__logo {
66
+ display: block;
67
+ margin: 0 auto;
68
+ width: 100%;
69
+ height: 4.8em;
70
+ background: transparent url('/favicon.svg') no-repeat center center;
71
+ background-size: contain;
72
+ }
73
+ .header__title {
74
+ display: block;
75
+ text-align: center;
76
+ margin: 0.4em 0 0.8em;
77
+ font-size: 2.1em;
78
+ font-weight: bold;
79
+ }
80
+
81
+ .todo {
82
+ position: relative;
83
+ margin-bottom: 2.4em;
84
+ padding: 0 0 0 4.2em;
85
+ }
86
+ .todo:not(:last-child):after {
87
+ content: '';
88
+ position: absolute;
89
+ inset: 0 auto -2.4em 1.3em;
90
+ width: 0.2em;
91
+ background: rgba(var(--color-primary-rgb), 0.1);
92
+ z-index: -2;
93
+ }
94
+ .todo__number {
95
+ display: block;
96
+ position: absolute;
97
+ top: 0;
98
+ left: 0;
99
+ width: 2.4em;
100
+ height: 2.4em;
101
+ line-height: 2.4em;
102
+ border-radius: 1.2em;
103
+ text-align: center;
104
+ font-size: 1.2em;
105
+ font-weight: bold;
106
+ user-select: none;
107
+ background: rgba(var(--color-primary-rgb), 0.15);
108
+ color: var(--color-primary);
109
+ }
110
+ .todo__number:before {
111
+ content: '';
112
+ position: absolute;
113
+ top: -0.5em;
114
+ left: -0.5em;
115
+ width: 3.4em;
116
+ height: 3.4em;
117
+ border-radius: 1.7em;
118
+ background: var(--color-white);
119
+ z-index: -1;
120
+ }
121
+ .todo__title {
122
+ padding: 0.5em 0 0.2em;
123
+ line-height: 1.4em;
124
+ font-size: 1.2em;
125
+ font-weight: bold;
126
+ opacity: 0.8;
127
+ }
128
+
129
+ .button {
130
+ display: inline-block;
131
+ margin: 0.8em 0 0;
132
+ padding: 0.6em 1.2em;
133
+ border-radius: 0.6em;
134
+
135
+ font: inherit;
136
+ color: var(--color-white);
137
+ background: var(--color-primary);
138
+ border: none;
139
+ outline: none;
140
+
141
+ cursor: pointer;
142
+ }
143
+ .button:not(.button--ok):not(:active):hover {
144
+ box-shadow: 0 0 1.2em rgba(0, 0, 0, 0.1);
145
+ }
146
+ .button:active,
147
+ .button--ok {
148
+ box-shadow: 0 0 1.2em rgba(0, 0, 0, 0.2);
149
+ background: var(--color-primary-dark);
150
+ }
151
+ .button--ok {
152
+ pointer-events: none;
153
+ color: transparent;
154
+ background: var(--color-primary) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M5 12l5 5l10 -10"></path></svg>') center center no-repeat;
155
+ }
156
+
157
+ .footer {
158
+ margin: 4.8em 0 0;
159
+ }
160
+ .footer__text {
161
+ text-align: center;
162
+ font-size: 0.8em;
163
+ margin: 0;
164
+ opacity: 0.6;
165
+ }
@@ -0,0 +1,61 @@
1
+ -- CreateTable
2
+ CREATE TABLE `User` (
3
+ `id` VARCHAR(191) NOT NULL,
4
+ `prefix` VARCHAR(191) NOT NULL,
5
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
6
+ `lastSeenAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
7
+
8
+ UNIQUE INDEX `User_prefix_key`(`prefix`),
9
+ PRIMARY KEY (`id`)
10
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
11
+
12
+ -- CreateTable
13
+ CREATE TABLE `Event` (
14
+ `id` VARCHAR(191) NOT NULL,
15
+ `orderId` VARCHAR(191) NOT NULL,
16
+ `from` DATETIME(3) NOT NULL,
17
+ `to` DATETIME(3) NOT NULL,
18
+ `amount` INTEGER NOT NULL,
19
+ `price` INTEGER NOT NULL,
20
+ `userId` VARCHAR(191) NOT NULL,
21
+ `locationId` VARCHAR(191) NOT NULL,
22
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
23
+ `orderedAt` DATETIME(3) NOT NULL,
24
+ `invoicedAt` DATETIME(3) NULL,
25
+ `canceledAt` DATETIME(3) NULL,
26
+
27
+ UNIQUE INDEX `Event_orderId_key`(`orderId`),
28
+ PRIMARY KEY (`id`)
29
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
30
+
31
+ -- CreateTable
32
+ CREATE TABLE `Location` (
33
+ `id` VARCHAR(191) NOT NULL,
34
+ `name` VARCHAR(191) NOT NULL,
35
+ `address` VARCHAR(191) NOT NULL,
36
+ `latitude` DOUBLE NULL,
37
+ `longitude` DOUBLE NULL,
38
+ `emoji` VARCHAR(16) NULL,
39
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
40
+
41
+ PRIMARY KEY (`id`)
42
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
43
+
44
+ -- CreateTable
45
+ CREATE TABLE `Mail` (
46
+ `id` VARCHAR(191) NOT NULL,
47
+ `raw` MEDIUMTEXT NOT NULL,
48
+ `error` MEDIUMTEXT NULL,
49
+ `errorId` VARCHAR(191) NULL,
50
+ `version` VARCHAR(191) NULL,
51
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
52
+ `erroredAt` DATETIME(3) NULL,
53
+
54
+ PRIMARY KEY (`id`)
55
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
56
+
57
+ -- AddForeignKey
58
+ ALTER TABLE `Event` ADD CONSTRAINT `Event_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
59
+
60
+ -- AddForeignKey
61
+ ALTER TABLE `Event` ADD CONSTRAINT `Event_locationId_fkey` FOREIGN KEY (`locationId`) REFERENCES `Location`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (i.e. Git)
3
+ provider = "mysql"
@@ -0,0 +1,57 @@
1
+ // This is your Prisma schema file,
2
+ // learn more about it in the docs: https://pris.ly/d/prisma-schema
3
+
4
+ generator client {
5
+ provider = "prisma-client-js"
6
+ }
7
+
8
+ datasource db {
9
+ provider = "mysql"
10
+ url = env("DATABASE_URL")
11
+ }
12
+
13
+ model User {
14
+ id String @id @default(uuid())
15
+ prefix String @unique
16
+ createdAt DateTime @default(now())
17
+ lastSeenAt DateTime @default(now())
18
+ event Event[]
19
+ }
20
+
21
+ model Event {
22
+ id String @id @default(uuid())
23
+ orderId String @unique
24
+ from DateTime
25
+ to DateTime
26
+ amount Int
27
+ price Int
28
+ user User @relation(fields: [userId], references: [id])
29
+ userId String
30
+ location Location @relation(fields: [locationId], references: [id])
31
+ locationId String
32
+ createdAt DateTime @default(now())
33
+ orderedAt DateTime
34
+ invoicedAt DateTime?
35
+ canceledAt DateTime?
36
+ }
37
+
38
+ model Location {
39
+ id String @id @default(uuid())
40
+ name String
41
+ address String
42
+ latitude Float?
43
+ longitude Float?
44
+ emoji String? @db.VarChar(16)
45
+ createdAt DateTime @default(now())
46
+ event Event[]
47
+ }
48
+
49
+ model Mail {
50
+ id String @id @default(uuid())
51
+ raw String @db.MediumText
52
+ error String? @db.MediumText
53
+ errorId String?
54
+ version String?
55
+ createdAt DateTime @default(now())
56
+ erroredAt DateTime?
57
+ }
@@ -0,0 +1,81 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>TGTG → iCal</title>
8
+
9
+ <meta name="title" content="TGTG → iCal" />
10
+ <meta name="description" content="Create a subscribable calendar with your TGTG pickup dates so you never miss them." />
11
+ <meta property="og:type" content="website" />
12
+ <meta property="og:url" content="https://tgtg-ical.sebbo.net" />
13
+ <meta property="og:title" content="TGTG → iCal" />
14
+ <meta property="og:description" content="Create a subscribable calendar with your TGTG pickup dates so you never miss them." />
15
+ <meta property="og:image" content="/preview.png" />
16
+
17
+ <link rel="stylesheet" href="/style.css">
18
+ </head>
19
+ <body>
20
+ <div class="app">
21
+ <header class="header">
22
+ <span class="header__logo">&nbsp;</span>
23
+ <h1 class="header__title">TGTG → iCal</h1>
24
+ </header>
25
+ <main class="main">
26
+ <ul class="main__todos">
27
+ <li class="todo">
28
+ <span class="todo__number">1</span>
29
+ <h3 class="todo__title">Subscribe to your calendar feed</h3>
30
+ <p class="todo__notes">
31
+ Calendars can be subscribed to in all common calendar apps,
32
+ e.g. Google Calendar, Apple Calendar, Outlook, etc.
33
+ </p>
34
+ <button class="button button--copy" data-copy="${CALENDAR_URL}">Copy calendar URL</button>
35
+ </li>
36
+ <li class="todo">
37
+ <span class="todo__number">2</span>
38
+ <h3 class="todo__title">Forward your TGTG emails</h3>
39
+ <p class="todo__notes">
40
+ It is best to set up a forwarding rule with your email provider so that mails from TGTG are
41
+ automatically forwarded to your personal email address.
42
+ </p>
43
+ <button class="button button--copy" data-copy="${EMAIL_ADDRESS}">Copy email address</button>
44
+ </li>
45
+ <li class="todo">
46
+ <span class="todo__number">3</span>
47
+ <h3 class="todo__title">Tadaa. 🎉</h3>
48
+ <p class="todo__notes">
49
+ From now on your forwarded mails by TGTG will be automatically
50
+ analyzed and displayed in your calendar. If not, feel free to
51
+ <a href="https://github.com/sebbo2002/tgtg-ical/issues/new" target="_blank" rel="noopener noreferrer">report a bug</a> :)
52
+ </p>
53
+ </li>
54
+ </ul>
55
+ <footer class="footer">
56
+ <p class="footer__text">
57
+ Made by <a href="https://sebbo.net">Sebastian Pekarek</a> |
58
+ Hosted on <a href="https://uberspace.de" target="_blank" rel="noopener noreferrer">Asteroids</a> |
59
+ Not related to <a href="https://www.toogoodtogo.com/" target="_blank" rel="noopener noreferrer">Too Good To Go</a> |
60
+ Uses <a href="https://openstreetmap.org/" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> for Geocoding |
61
+ <a href="https://sebbo.net/contact" target="_blank" rel="noopener noreferrer">Impressum</a> |
62
+ <a href="https://github.com/sebbo2002/tgtg-ical" target="_blank" rel="noopener noreferrer">OpenSource on GitHub</a> |
63
+ <a href="https://github.com/sebbo2002/tgtg-ical/issues/new" target="_blank" rel="noopener noreferrer">Report a bug</a>
64
+ </footer>
65
+ <img src="https://p.sebbo.net/api?idsite=5&amp;rec=1" referrerpolicy="no-referrer-when-downgrade" style="border:0" alt="" />
66
+ </main>
67
+ </div>
68
+ <script>
69
+ document.querySelectorAll('.button--copy').forEach(button => {
70
+ button.addEventListener('click', () => {
71
+ navigator.clipboard.writeText(button.attributes['data-copy'].value);
72
+ button.classList.add('button--ok');
73
+
74
+ setTimeout(() => {
75
+ button.classList.remove('button--ok');
76
+ }, 1000);
77
+ });
78
+ });
79
+ </script>
80
+ </body>
81
+ </html>