├── .env.sample
├── .gitignore
├── .vscode
├── launch.json
└── settings.json
├── Dockerfile
├── README.md
├── app.vue
├── assets
├── css
│ └── main.css
└── scss
│ └── main.scss
├── components
├── MessageEditor.vue
├── ThreadEditor.vue
├── ThreadSummaryList.vue
└── UserSettings.vue
├── docker-compose.yml
├── docs
├── bullmq_console.png
├── minio_bucket_access_policy.png
├── minio_configure_anonymous_access_rule.png
├── minio_configure_region.png
├── minio_create_bucket.png
├── minio_login.png
└── threadr-v0.5.0.png
├── models
└── models.ts
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── pages
└── index.vue
├── plugins
└── primevue.js
├── prisma
├── db.ts
├── migrations
│ ├── 20230906200241_init
│ │ └── migration.sql
│ └── 20230929155742_add_settings_table
│ │ ├── down.sql
│ │ └── migration.sql
└── schema.prisma
├── public
└── favicon.ico
├── scripts
└── test_prepare.sh
├── server
├── api
│ ├── media.post.ts
│ ├── settings.get.ts
│ ├── settings.patch.ts
│ ├── threads.get.ts
│ ├── threads.post.ts
│ └── threads
│ │ ├── [id].delete.ts
│ │ ├── [id].get.ts
│ │ ├── [id].post.ts
│ │ └── [id]
│ │ ├── publication.post.ts
│ │ ├── schedule.delete.ts
│ │ └── schedule.post.ts
├── settings.get.spec.ts
├── settings.patch.spec.ts
└── tsconfig.json
├── services
├── bluesky-url-facets-extractor.spec.ts
├── bluesky-url-facets-extractor.ts
└── publication-service.ts
├── threadr.png
├── tsconfig.json
└── utils
├── queue.ts
├── test-utils.ts
└── utils.ts
/.env.sample:
--------------------------------------------------------------------------------
1 | # ----------
2 | # S3 Storage
3 | # ----------
4 |
5 | # MinIO config for docker-compose
6 | MINIO_ROOT_USER=minio-admin
7 | MINIO_ROOT_PASSWORD=my-secured-password
8 |
9 | MINIO_ENDPOINT=http://localhost:9000
10 | MINIO_REGION=eu-fr-1
11 | MINIO_BUCKET_NAME=threadr-app
12 | MINIO_MEDIA_PATH=media
13 | MINIO_ACCESS_KEY="${MINIO_ROOT_USER}"
14 | MINIO_SECRET_KEY="${MINIO_ROOT_PASSWORD}"
15 |
16 | # ----------------
17 | # Data persistence
18 | # ----------------
19 | POSTGRES_USER=postgres-admin
20 | POSTGRES_PASSWORD=postgres-password
21 | POSTGRES_DB=threadr
22 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public"
23 |
24 | # ---------------
25 | # Scheduling jobs
26 | # ---------------
27 | REDIS_URL=redis://localhost:6379
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | # Nuxt
133 | .output
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "firefox",
9 | "request": "launch",
10 | "name": "client: firefox",
11 | "url": "http://localhost:3000",
12 | "webRoot": "${workspaceFolder}"
13 | },
14 | {
15 | "type": "node",
16 | "request": "launch",
17 | "name": "server: nuxt",
18 | "outputCapture": "std",
19 | "program": "${workspaceFolder}/node_modules/nuxi/bin/nuxi.mjs",
20 | "args": [
21 | "dev"
22 | ],
23 | }
24 | ],
25 | "compounds": [
26 | {
27 | "name": "fullstack: nuxt",
28 | "configurations": [
29 | "server: nuxt",
30 | "client: firefox"
31 | ]
32 | }
33 | ]
34 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "nuxt.isNuxtApp": false,
4 | "sqltools.connections": [
5 | {
6 | "previewLimit": 50,
7 | "server": "localhost",
8 | "driver": "PostgreSQL",
9 | "name": "Localhost",
10 | "connectString": "postgresql://postgres-admin:postgres-password@localhost:5432/threadr?schema=public"
11 | },
12 | {
13 | "previewLimit": 50,
14 | "server": "localhost",
15 | "driver": "PostgreSQL",
16 | "name": "Test",
17 | "connectString": "postgresql://threadr_test:threadr_test@localhost:5433/threadr_test?schema=public"
18 | }
19 | ]
20 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG NODE_VERSION=20-alpine3.17
2 |
3 | FROM node:${NODE_VERSION} as base
4 |
5 | ARG PORT=3000
6 |
7 | ENV NODE_ENV=production
8 |
9 | WORKDIR /app
10 |
11 | # Build
12 | FROM base as build
13 |
14 | COPY package.json package-lock.json ./
15 | RUN npm install --production=false
16 |
17 | COPY . .
18 |
19 | RUN npm run build
20 | RUN npm prune
21 |
22 | # Run
23 | FROM base
24 |
25 | ENV PORT=$PORT
26 |
27 | COPY --from=build /app/.output /app/.output
28 | # Optional, only needed if you rely on unbundled dependencies
29 | # COPY --from=build /src/node_modules /src/node_modules
30 |
31 | CMD [ "node", ".output/server/index.mjs" ]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Threadr
2 |
3 | Threadr is a small (#workinprogress) web application that helps users of micro-blogging platforms to write great threads and allows them to crosspost in one click their content to Bluesky, Mastodon and Twitter/X.
4 |
5 | 
6 |
7 | ## Core features
8 |
9 | * Write threads
10 | * Add, edit, remove messages
11 | * Add, describe, remove message images (up to 4 by message)
12 | * Save a thread
13 | * List all threads (by status, e.g. `draft`, `scheduled`, `published`)
14 | * Publish a thread
15 | * Schedule (and cancel) a thread publication
16 | * Configure platforms in a settings manager
17 | * display name
18 | * avatar
19 | * Bluesky activation and configuration
20 | * Mastodon activation and configuration
21 | * Twitter activation and configuration
22 |
23 | ## Installation
24 |
25 | For now, Threadr only works in localhost.
26 |
27 | **1/** Get the project sources
28 |
29 | ```shell
30 | $ git clone git@github.com:jbuget/threadr-app.git && cd threadr-app
31 | ```
32 |
33 | **2/** Copy the `.env.sample` file into a new `.env` file
34 |
35 | You previously must generate, get and report your access keys for Twitter/X (new developer project), Mastodon (new app) and Bluesky (your user credentials).
36 |
37 | **3/** Run the Docker compose service `minio` (required to upload media files into platforms)
38 |
39 | ```shell
40 | $ docker compose up -d
41 | ```
42 |
43 | **4/** Configure your MinIO instance
44 |
45 | MinIO console is accessible on [localhost:9001](http://localhost:9001). Credentials are defined in `.env`file, cf. `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD`. By default, values are "minio-admin" / "my-secured-password".
46 |
47 | 
48 |
49 | Create a bucket (ex: "threadr-app" in `.env.sample`).
50 |
51 | 
52 |
53 | In the MinIO settings, configure MinIO region (ex: "eu-fr-1" in `.env.sample`).
54 |
55 | 
56 |
57 | > ⚠️ It is recommanded to declare a custom policy with dedicacted path in `readonly` acces for anonymous visitors
58 |
59 | 
60 |
61 | 
62 |
63 | **5/** Run Threadr locally
64 |
65 | ```shell
66 | $ npm install
67 | $ npm run dev -- -o
68 | ```
69 |
70 | **6/** 🚀 Enjoy Threadr at [localhost:3000](http://localhost:3000)!
71 |
72 |
73 |
74 |
75 |
76 | **7/** (bonus) You can follow your scheduled threads in BullMQ console
77 |
78 | Run the following command :
79 |
80 | ```shell
81 | $ npx bullmq-dashboard-runnable thread-schedules -P 3001
82 | ```
83 |
84 | Then access [localhost:3001](http://localhost:3001).
85 |
86 | 
87 |
88 | ## Configuration
89 |
90 | All configuration option are set in the `.env` file.
91 |
92 |
93 |
94 | Uploading media files
95 |
96 | **`MINIO_ENDPOINT`: URL**
97 |
98 | The endpoint URL of MinIO/S3 server on which temporarily upload media files.
99 |
100 | **`MINIO_REGION`: string**
101 |
102 | The region of the MinIO/S3 server.
103 |
104 | **`MINIO_BUCKET_NAME`: string**
105 |
106 | The bucket where the media files will be upload before being sent to platforms.
107 |
108 | **`MINIO_MEDIA_PATH`: string**
109 |
110 | The folder path inside the bucket.
111 |
112 | **`MINIO_ACCESS_KEY`: string**
113 |
114 | The MinIO access key to access the bucket in order to deposit media files.
115 |
116 | **`MINIO_SECRET_KEY`: string**
117 |
118 | The MinIO access secret key to access the bucket in order to deposit media files.
119 |
120 | **`MINIO_ROOT_USER`: string**
121 |
122 | The MinIO administration account username (used for docker-compose MinIO container).
123 |
124 | **`MINIO_ROOT_PASSWORD`: string**
125 |
126 | The MinIO administration account password (used for docker-compose MinIO container).
127 |
128 |
129 |
130 |
131 | Persisting data
132 |
133 | **`DATABASE_URL`: (PostgreSQL) URL**
134 |
135 | The PostgreSQL database URL
136 |
137 | **`POSTGRES_USER`: string**
138 |
139 | The PostgreSQL administration account username (used for docker-compose postgres container).
140 |
141 | **`POSTGRES_PASSWORD`: string**
142 |
143 | The PostgreSQL administration account password (used for docker-compose postgres container).
144 |
145 | **`POSTGRES_DB`: string**
146 |
147 | The PostgreSQL database.
148 |
149 |
150 |
151 |
152 |
153 | Scheduling thread publication
154 |
155 | **`REDIS_URL`: (Redis) URL**
156 |
157 | The Redis database URL, used by BullMQ.
158 |
159 |
--------------------------------------------------------------------------------
/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/assets/css/main.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | font-family: 'Mulish', Arial, Helvetica, sans-serif;
3 | padding: 0;
4 | margin: 0;
5 | font-size: 16px;
6 | }
7 |
8 | h1, h2, h3, h4, h5, h6 {
9 | font-family: 'Lato', Arial, Helvetica, sans-serif;
10 | }
11 |
12 | textarea {
13 | font-family: 'Mulish', Arial, Helvetica, sans-serif;
14 | font-size: 1em;
15 | }
16 |
--------------------------------------------------------------------------------
/assets/scss/main.scss:
--------------------------------------------------------------------------------
1 | @import "primeicons/primeicons.css";
2 |
3 | .p-datepicker .p-datepicker-header {
4 | padding: 0 !important;
5 | }
6 |
7 | .p-datepicker table td {
8 | padding: 0.25rem !important;
9 | }
10 | .p-datepicker table td > span {
11 | width: 2rem !important;
12 | height: 2rem !important;
13 | }
14 |
15 | .p-datepicker .p-datepicker-buttonbar {
16 | padding: 0 !important;
17 | }
18 |
19 | .p-dialog .p-dialog-header {
20 | border-bottom: 1px solid lightgray!important;
21 | }
22 | .p-dialog .p-dialog-footer {
23 | border-top: 1px solid lightgray!important;
24 | padding: 1.5rem!important;
25 | }
26 |
--------------------------------------------------------------------------------
/components/MessageEditor.vue:
--------------------------------------------------------------------------------
1 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | {{ settings.display_name }}
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
102 |
103 |
104 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
{{ message.text.length }} / 280
113 |
#{{ index + 1 }}
114 |
115 |
116 |
119 |
120 |
121 |
122 |
124 |
125 |
126 |
128 |
129 |
130 |
131 |
143 |
144 |
145 |
146 |
--------------------------------------------------------------------------------
/components/ThreadEditor.vue:
--------------------------------------------------------------------------------
1 |
219 |
220 |
221 |
222 |
223 |
224 |
245 |
246 |
247 |
248 |
249 | {{ threadMessage }}
250 |
251 |
252 |
253 | {{ threadMessage }}
254 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
267 |
268 |
269 |
270 |
271 |
272 |
280 |
281 |
282 |
283 |
284 |
--------------------------------------------------------------------------------
/components/ThreadSummaryList.vue:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Drafts
60 |
61 |
62 |
63 |
64 |
65 | {{ (new Date(thread.createdAt)).toLocaleString() }} · {{ thread.nbMessages }} messages
66 |
67 |
{{ thread.title }}
68 |
69 |
70 |
71 |
72 |
73 |
74 | Scheduled
75 |
76 |
77 |
78 |
79 |
80 | {{ (new Date(thread.createdAt)).toLocaleString() }} · {{ thread.nbMessages }} messages
81 |
82 |
{{ thread.title }}
83 |
84 |
85 |
86 |
87 |
88 |
89 | Published
90 |
91 |
92 |
93 |
94 |
95 | {{ (new Date(thread.createdAt)).toLocaleString() }} · {{ thread.nbMessages }} messages
96 |
97 |
{{ thread.title }}
98 |
99 |
100 |
101 |
102 |
103 |
Loading threads…
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/components/UserSettings.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
24 |
25 |
26 |
Platforms
27 |
28 |
29 |
30 | Bluesky
31 |
32 |
33 |
51 |
52 |
53 |
54 |
55 | Mastodon
56 |
57 |
58 |
71 |
72 |
73 |
74 |
75 | Twitter
76 |
77 |
78 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 |
5 | minio:
6 | image: minio/minio
7 | command: 'server --console-address ":9001" /data'
8 | env_file:
9 | - .env
10 | ports:
11 | - "9000:9000"
12 | - "9001:9001"
13 | volumes:
14 | - minio_storage:/data
15 |
16 | postgres:
17 | image: postgres:15.4-alpine
18 | env_file:
19 | - .env
20 | ports:
21 | - "5432:5432"
22 | volumes:
23 | - pgdata:/var/lib/postgresql/data
24 |
25 | postgres_test:
26 | image: postgres:15.4-alpine
27 | ports:
28 | - "5433:5432"
29 |
30 | redis:
31 | image: redis:7.2-alpine
32 | ports:
33 | - "6379:6379"
34 | command: redis-server --save 60 1 --loglevel warning
35 | volumes:
36 | - redis:/data
37 |
38 | volumes:
39 | pgdata:
40 | redis:
41 | minio_storage:
42 |
--------------------------------------------------------------------------------
/docs/bullmq_console.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/bullmq_console.png
--------------------------------------------------------------------------------
/docs/minio_bucket_access_policy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_bucket_access_policy.png
--------------------------------------------------------------------------------
/docs/minio_configure_anonymous_access_rule.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_configure_anonymous_access_rule.png
--------------------------------------------------------------------------------
/docs/minio_configure_region.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_configure_region.png
--------------------------------------------------------------------------------
/docs/minio_create_bucket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_create_bucket.png
--------------------------------------------------------------------------------
/docs/minio_login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/minio_login.png
--------------------------------------------------------------------------------
/docs/threadr-v0.5.0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/docs/threadr-v0.5.0.png
--------------------------------------------------------------------------------
/models/models.ts:
--------------------------------------------------------------------------------
1 | export interface Attachment {
2 | location: string
3 | filename: string
4 | size: number
5 | mimetype: string
6 | alt?: string
7 | }
8 |
9 | export interface Message {
10 | text: string;
11 | attachments: Attachment[];
12 | }
13 |
14 |
15 | export interface Thread {
16 | id?: number
17 | messages?: Message[]
18 | createdAt?: Date
19 | updatedAt?: Date
20 | scheduledAt?: Date | undefined
21 | publishedAt?: Date | undefined
22 | }
23 | export interface ThreadSummary {
24 | id?: number
25 | title: string
26 | createdAt?: Date
27 | updatedAt?: Date
28 | scheduledAt?: Date
29 | publishedAt?: Date
30 | nbMesssages: number
31 | }
32 |
33 | export interface Settings {
34 | id?: number
35 | createdAt?: Date
36 | updatedAt?: Date
37 | displayName?: string
38 | avatarUrl?: string
39 | blueskyEnabled: boolean
40 | blueskyUrl?: string
41 | blueskyIdentifier?: string
42 | blueskyPassword?: string
43 | mastodonEnabled: boolean
44 | mastodonUrl?: string
45 | mastodonAccessToken?: string
46 | twitterEnabled: boolean
47 | twitterConsumerKey?: string
48 | twitterConsumerSecret?: string
49 | twitterAccessToken?: string
50 | twitterAccessSecret?: string
51 | }
52 |
--------------------------------------------------------------------------------
/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | const modules = (() => {
2 | if (process.env.NODE_ENV === 'test') {
3 | return []
4 | }
5 | return ['@nuxtjs/google-fonts']
6 | })()
7 |
8 | // @ts-nocheck
9 | // https://nuxt.com/docs/api/configuration/nuxt-config
10 | export default defineNuxtConfig({
11 | devtools: { enabled: true },
12 | typescript: {
13 | strict: true
14 | },
15 | css: [
16 | '@/assets/css/main.css',
17 | '@/assets/scss/main.scss',
18 | 'primevue/resources/themes/lara-light-blue/theme.css'
19 | ],
20 | build: {
21 | transpile: ["primevue"]
22 | },
23 | modules: modules,
24 | googleFonts: {
25 | families: {
26 | Lato: true,
27 | Roboto: true,
28 | Mulish: true
29 | }
30 | },
31 | runtimeConfig: {
32 | public: {
33 | displayingName: process.env.DISPLAYING_NAME,
34 | avatarUrl: process.env.AVATAR_URL,
35 | }
36 | },
37 | test: process.env.NODE_ENV === 'test',
38 | })
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "threader",
3 | "private": true,
4 | "scripts": {
5 | "db:migrate": "npx prisma migrate deploy",
6 | "build": "nuxt build",
7 | "dev": "npm run db:migrate && nuxt dev",
8 | "test": "npm run test:prepare && npm run test:run",
9 | "test:prepare": "./scripts/test_prepare.sh",
10 | "test:run": "NODE_ENV=test vitest --run --no-threads",
11 | "generate": "nuxt generate",
12 | "preview": "nuxt preview",
13 | "postinstall": "nuxt prepare"
14 | },
15 | "devDependencies": {
16 | "@atproto/api": "^0.6.6",
17 | "@aws-sdk/client-s3": "^3.400.0",
18 | "@aws-sdk/lib-storage": "^3.400.0",
19 | "@nuxt/devtools": "latest",
20 | "@nuxt/test-utils": "^3.7.4",
21 | "@nuxtjs/google-fonts": "^3.0.2",
22 | "@prisma/client": "^5.3.1",
23 | "@types/formidable": "^3.4.3",
24 | "@types/uuid": "^9.0.4",
25 | "bullmq": "^4.12.0",
26 | "formidable": "^3.5.1",
27 | "ioredis": "^5.3.2",
28 | "masto": "^6.3.1",
29 | "minio": "^7.1.2",
30 | "nuxt": "^3.7.4",
31 | "pg": "^8.11.3",
32 | "primeicons": "^6.0.1",
33 | "primevue": "^3.35.0",
34 | "prisma": "^5.3.1",
35 | "sass": "^1.66.1",
36 | "twitter-api-v2": "^1.15.1",
37 | "vitest": "^0.33.0"
38 | },
39 | "dependencies": {
40 | "@types/pg": "^8.10.3"
41 | }
42 | }
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
109 |
110 |
115 |
116 |
117 |
127 |
128 |
129 |
--------------------------------------------------------------------------------
/plugins/primevue.js:
--------------------------------------------------------------------------------
1 | import { defineNuxtPlugin } from "#app";
2 | import PrimeVue from "primevue/config";
3 | import Avatar from "primevue/avatar";
4 | import Badge from "primevue/badge";
5 | import Button from "primevue/button";
6 | import Chip from "primevue/chip";
7 | import Dialog from 'primevue/dialog';
8 | import Divider from "primevue/divider";
9 | import FileUpload from "primevue/fileupload";
10 | import Image from "primevue/image";
11 | import InlineMessage from "primevue/inlinemessage";
12 | import InputSwitch from "primevue/inputswitch";
13 | import InputText from 'primevue/inputtext';
14 | import Message from 'primevue/message';
15 | import Textarea from "primevue/textarea";
16 | import Toast from "primevue/toast";
17 | import ToastService from 'primevue/toastservice';
18 |
19 | export default defineNuxtPlugin((nuxtApp) => {
20 | nuxtApp.vueApp.use(PrimeVue, { ripple: true });
21 | nuxtApp.vueApp.use(ToastService)
22 | nuxtApp.vueApp.component("Avatar", Avatar);
23 | nuxtApp.vueApp.component("Badge", Badge);
24 | nuxtApp.vueApp.component("Button", Button);
25 | nuxtApp.vueApp.component("Chip", Chip);
26 | nuxtApp.vueApp.component("Dialog", Dialog);
27 | nuxtApp.vueApp.component("Divider", Divider);
28 | nuxtApp.vueApp.component("FileUpload", FileUpload);
29 | nuxtApp.vueApp.component("Image", Image);
30 | nuxtApp.vueApp.component("InlineMessage", InlineMessage);
31 | nuxtApp.vueApp.component("InputSwitch", InputSwitch);
32 | nuxtApp.vueApp.component("InputText", InputText);
33 | nuxtApp.vueApp.component("Message", Message);
34 | nuxtApp.vueApp.component("Textarea", Textarea);
35 | nuxtApp.vueApp.component("Toast", Toast);
36 | //other components that you need
37 | });
38 |
39 |
--------------------------------------------------------------------------------
/prisma/db.ts:
--------------------------------------------------------------------------------
1 | // https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices
2 | import { PrismaClient } from '@prisma/client'
3 |
4 | if (process.env.NODE_ENV === 'test') {
5 | process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'];
6 | }
7 |
8 | const prismaClientSingleton = () => {
9 | return new PrismaClient()
10 | }
11 |
12 | type PrismaClientSingleton = ReturnType
13 |
14 | const globalForPrisma = globalThis as unknown as {
15 | prisma: PrismaClientSingleton | undefined
16 | }
17 |
18 | export const prisma = globalForPrisma.prisma ?? prismaClientSingleton()
19 |
20 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
--------------------------------------------------------------------------------
/prisma/migrations/20230906200241_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Thread" (
3 | "id" SERIAL NOT NULL,
4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | "updatedAt" TIMESTAMP(3) NULL,
6 | "scheduledAt" TIMESTAMP(3) NULL,
7 | "publishedAt" TIMESTAMP(3) NULL,
8 | "published" BOOLEAN NOT NULL DEFAULT false,
9 |
10 | CONSTRAINT "Thread_pkey" PRIMARY KEY ("id")
11 | );
12 |
13 | -- CreateTable
14 | CREATE TABLE "Version" (
15 | "id" SERIAL NOT NULL,
16 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17 | "data" JSONB NOT NULL,
18 | "threadId" INTEGER NOT NULL,
19 |
20 | CONSTRAINT "Version_pkey" PRIMARY KEY ("id")
21 | );
22 |
23 | -- AddForeignKey
24 | ALTER TABLE "Version" ADD CONSTRAINT "Version_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "Thread"("id") ON DELETE CASCADE ON UPDATE CASCADE;
25 |
--------------------------------------------------------------------------------
/prisma/migrations/20230929155742_add_settings_table/down.sql:
--------------------------------------------------------------------------------
1 | DELETE FROM _prisma_migrations
2 | WHERE id='484592f3-8e79-4de3-8f2e-214347cd6b39';
--------------------------------------------------------------------------------
/prisma/migrations/20230929155742_add_settings_table/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Settings" (
3 | "id" SERIAL NOT NULL,
4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | "updatedAt" TIMESTAMP(3),
6 | "displayName" TEXT,
7 | "avatarUrl" TEXT,
8 | "blueskyEnabled" BOOLEAN NOT NULL DEFAULT false,
9 | "blueskyUrl" TEXT,
10 | "blueskyIdentifier" TEXT,
11 | "blueskyPassword" TEXT,
12 | "mastodonEnabled" BOOLEAN NOT NULL DEFAULT false,
13 | "mastodonUrl" TEXT,
14 | "mastodonAccessToken" TEXT,
15 | "twitterEnabled" BOOLEAN NOT NULL DEFAULT false,
16 | "twitterConsumerKey" TEXT,
17 | "twitterConsumerSecret" TEXT,
18 | "twitterAccessToken" TEXT,
19 | "twitterAccessSecret" TEXT,
20 |
21 | CONSTRAINT "Settings_pkey" PRIMARY KEY ("id")
22 | );
23 |
24 | INSERT INTO "Settings" (id)
25 | SELECT 1
26 | WHERE NOT EXISTS (SELECT 1 FROM "Settings");
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
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 = "postgresql"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model Thread {
14 | id Int @id @default(autoincrement())
15 | createdAt DateTime @default(now())
16 | updatedAt DateTime? @updatedAt
17 | scheduledAt DateTime?
18 | publishedAt DateTime?
19 | published Boolean @default(false)
20 | versions Version[]
21 | }
22 |
23 | model Version {
24 | id Int @id @default(autoincrement())
25 | createdAt DateTime @default(now())
26 | data Json
27 | thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
28 | threadId Int
29 | }
30 |
31 | model Settings {
32 | id Int @id @default(autoincrement())
33 | createdAt DateTime @default(now())
34 | updatedAt DateTime?
35 | displayName String?
36 | avatarUrl String?
37 | blueskyEnabled Boolean @default(false)
38 | blueskyUrl String?
39 | blueskyIdentifier String?
40 | blueskyPassword String?
41 | mastodonEnabled Boolean @default(false)
42 | mastodonUrl String?
43 | mastodonAccessToken String?
44 | twitterEnabled Boolean @default(false)
45 | twitterConsumerKey String?
46 | twitterConsumerSecret String?
47 | twitterAccessToken String?
48 | twitterAccessSecret String?
49 | }
50 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/public/favicon.ico
--------------------------------------------------------------------------------
/scripts/test_prepare.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Définition des variables pour la connexion à la base de données
4 | POSTGRES_USER="threadr_test"
5 | POSTGRES_PASSWORD="threadr_test"
6 | POSTGRES_DB="threadr_test"
7 | POSTGRES_HOST="localhost"
8 | POSTGRES_PORT="5433"
9 | POSTGRES_SCHEMA="public"
10 |
11 | # Construction de l'URL de la base de données
12 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=${POSTGRES_SCHEMA}"
13 |
14 | # Exporter DATABASE_URL comme une variable d'environnement
15 | export DATABASE_URL
16 |
17 | # Vérifiez si npx est installé
18 | command -v npx >/dev/null 2>&1 || {
19 | echo >&2 "npx n'est pas installé. Installez-le en utilisant npm install -g npx et réessayez.";
20 | exit 1;
21 | }
22 |
23 | # Déployez les migrations avec Prisma
24 | npx prisma migrate deploy
25 |
26 | # Vérifiez le succès de la migration
27 | if [ $? -eq 0 ]; then
28 | echo "Migration effectuée avec succès."
29 | else
30 | echo "Erreur lors de la migration. Vérifiez les logs ci-dessus pour plus de détails."
31 | exit 1;
32 | fi
--------------------------------------------------------------------------------
/server/api/media.post.ts:
--------------------------------------------------------------------------------
1 | import formidable, { File } from 'formidable'
2 | import { v4 as uuid } from 'uuid'
3 | import { PassThrough, Writable } from 'node:stream'
4 | import { S3Client } from '@aws-sdk/client-s3'
5 | import { Upload } from '@aws-sdk/lib-storage'
6 | import { IncomingMessage } from 'http'
7 |
8 | const s3Client = new S3Client({
9 | endpoint: process.env.MINIO_ENDPOINT as string,
10 | region: process.env.MINIO_REGION as string,
11 | credentials: {
12 | accessKeyId: process.env.MINIO_ACCESS_KEY as string,
13 | secretAccessKey: process.env.MINIO_SECRET_KEY as string
14 | },
15 | /*
16 | Required with MinIO in order to allow URL such as `minio.example.org/bucket_name`
17 | instead of default generated URL `bucket_name.mino.example.org`
18 | */
19 | forcePathStyle: true
20 | })
21 |
22 | function parseMultipartNodeRequest(req: IncomingMessage) {
23 | return new Promise((resolve, reject) => {
24 | const s3Uploads: Promise[] = [];
25 |
26 | function fileWriteStreamHandler(file?: any) : Writable {
27 | const body = new PassThrough();
28 | if (!file) {
29 | return body
30 | }
31 |
32 | /*
33 | On préfixe le nom de fichier par uuid() :
34 | - par mesure de sécurité afin d'éviter le scrapping ou le listing par brute force
35 | - afin de pouvoir uploader plusieurs fichiers différents portant le même nom
36 | */
37 | const fileObjectKey = `${process.env.MINIO_MEDIA_PATH as string}/${uuid()}-${file.originalFilename}`
38 |
39 | const upload = new Upload({
40 | client: s3Client,
41 | params: {
42 | Bucket: process.env.MINIO_BUCKET_NAME as string,
43 | Key: fileObjectKey,
44 | ContentType: file.mimetype,
45 | Body: body,
46 | },
47 | });
48 | const uploadRequest = upload.done().then((response:any) => {
49 | file.objectKey = fileObjectKey
50 | file.location = process.env.MINIO_ENDPOINT + '/' + process.env.MINIO_BUCKET_NAME + '/' + response.Key
51 | });
52 | s3Uploads.push(uploadRequest);
53 | return body;
54 | }
55 | const form = formidable({
56 | multiples: true,
57 | fileWriteStreamHandler: fileWriteStreamHandler,
58 | });
59 | form.parse(req, (error, fields, files) => {
60 | if (error) {
61 | reject(error);
62 | return;
63 | }
64 | Promise.all(s3Uploads)
65 | .then(() => {
66 | const response = { ...fields, ...files }
67 | resolve(response);
68 | })
69 | .catch(reject);
70 | });
71 | });
72 | }
73 |
74 | export default defineEventHandler(async (event: any) => {
75 | let body;
76 | const headers = getRequestHeaders(event);
77 |
78 | if (headers['content-type']?.includes('multipart/form-data')) {
79 | body = await parseMultipartNodeRequest(event.node.req);
80 | } else {
81 | body = await readBody(event);
82 | }
83 |
84 | /*
85 | cf. ~/node_modules/@types/formidable/index.d.ts
86 | default serialized fields: "size" | "filepath" | "originalFilename" | "mimetype" | "hash" | "newFilename"
87 | */
88 | const responseData = body.attachments.map((file:any) => {
89 | return {
90 | location: file.location,
91 | size: file.size,
92 | mimetype: file.mimetype,
93 | newFilename: file.fileObjectKey,
94 | originalFilename: file.originalFilename,
95 | description: ''
96 | }
97 | })
98 |
99 | return { files: responseData };
100 | });
101 |
--------------------------------------------------------------------------------
/server/api/settings.get.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../prisma/db'
2 |
3 | export default defineEventHandler(async () => {
4 | console.log(`GET /api/settings`)
5 |
6 | try {
7 | const settings: any = await prisma.settings.findFirst()
8 |
9 | if (settings) {
10 |
11 | return {
12 | display_name: settings.displayName,
13 | avatar_url: settings.avatarUrl,
14 | bluesky_enabled: settings.blueskyEnabled,
15 | bluesky_url: settings.blueskyUrl,
16 | bluesky_identifier: settings.blueskyIdentifier,
17 | bluesky_password: settings.blueskyPassword,
18 | mastodon_enabled: settings.mastodonEnabled,
19 | mastodon_url: settings.mastodonUrl,
20 | mastodon_access_token: settings.mastodonAccessToken,
21 | twitter_enabled: settings.twitterEnabled,
22 | twitter_consumer_key: settings.twitterConsumerKey,
23 | twitter_consumer_secret: settings.twitterConsumerSecret,
24 | twitter_access_token: settings.twitterAccessToken,
25 | twitter_access_secret: settings.twitterAccessSecret,
26 | }
27 | }
28 | return null
29 | } catch (error: any) {
30 | console.error(error)
31 | }
32 | })
33 |
--------------------------------------------------------------------------------
/server/api/settings.patch.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../prisma/db'
2 |
3 | export default defineEventHandler(async (event: any) => {
4 | console.log(`PATCH /api/settings`)
5 | try {
6 | const requestData: any = await readBody(event)
7 | const now = new Date()
8 |
9 | const existingRecord = await prisma.settings.findFirst()
10 |
11 | if (!existingRecord) {
12 | throw new Error('There is no settings defined in the system')
13 | }
14 |
15 | const data: any = {
16 | updatedAt: now,
17 | }
18 | if (typeof requestData.avatar_url !== 'undefined') data.avatarUrl = requestData.avatar_url
19 | if (typeof requestData.display_name !== 'undefined') data.displayName = requestData.display_name
20 | if (typeof requestData.bluesky_enabled !== 'undefined') data.blueskyEnabled = requestData.bluesky_enabled
21 | if (typeof requestData.bluesky_url !== 'undefined') data.blueskyUrl = requestData.bluesky_url
22 | if (typeof requestData.bluesky_identifier !== 'undefined') data.blueskyIdentifier = requestData.bluesky_identifier
23 | if (typeof requestData.bluesky_password !== 'undefined') data.blueskyPassword = requestData.bluesky_password
24 | if (typeof requestData.mastodon_enabled !== 'undefined') data.mastodonEnabled = requestData.mastodon_enabled
25 | if (typeof requestData.mastodon_url !== 'undefined') data.mastodonUrl = requestData.mastodon_url
26 | if (typeof requestData.mastodon_access_token !== 'undefined') data.mastodonAccessToken = requestData.mastodon_access_token
27 | if (typeof requestData.twitter_enabled !== 'undefined') data.twitterEnabled = requestData.twitter_enabled
28 | if (typeof requestData.twitter_consumer_key !== 'undefined') data.twitterConsumerKey = requestData.twitter_consumer_key
29 | if (typeof requestData.twitter_consumer_secret !== 'undefined') data.twitterConsumerSecret = requestData.twitter_consumer_secret
30 | if (typeof requestData.twitter_access_token !== 'undefined') data.twitterAccessToken = requestData.twitter_access_token
31 | if (typeof requestData.twitter_access_secret !== 'undefined') data.twitterAccessSecret = requestData.twitter_access_secret
32 |
33 | const [settings] = await prisma.$transaction([
34 | prisma.settings.update({
35 | where: {
36 | id: existingRecord.id
37 | },
38 | data,
39 | })
40 | ])
41 |
42 | const result: any = {
43 | id: settings.id,
44 | avatar_url: settings.avatarUrl,
45 | display_name: settings.displayName,
46 | bluesky_enabled: settings.blueskyEnabled,
47 | bluesky_url: settings.blueskyUrl,
48 | bluesky_identifier: settings.blueskyIdentifier,
49 | bluesky_password: settings.blueskyPassword,
50 | mastodon_enabled: settings.mastodonEnabled,
51 | mastodon_url: settings.mastodonUrl,
52 | mastodon_access_token: settings.mastodonAccessToken,
53 | twitter_enabled: settings.twitterEnabled,
54 | twitter_consumer_key: settings.twitterConsumerKey,
55 | twitter_consumer_secret: settings.twitterConsumerSecret,
56 | twitter_access_token: settings.twitterAccessToken,
57 | twitter_access_secret: settings.twitterAccessSecret,
58 | }
59 |
60 | return result;
61 |
62 | } catch (error: any) {
63 | console.error(error)
64 | }
65 | })
66 |
--------------------------------------------------------------------------------
/server/api/threads.get.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../prisma/db'
2 |
3 | export default defineEventHandler(async () => {
4 | console.log(`GET /api/threads`)
5 |
6 | const threads = await prisma.thread.findMany({
7 | orderBy: [{
8 | updatedAt: 'desc'
9 | }, {
10 | createdAt: 'desc'
11 | }],
12 | include: {
13 | versions: true
14 | }
15 | })
16 | const result = threads.map(thread => {
17 | const [latest] = thread.versions.slice(-1)
18 | const latestThreadData: any = latest.data
19 |
20 | let title = `Thread #${thread.id}`
21 | if (latestThreadData.messages.length > 0) {
22 | title = latestThreadData.messages[0].text.slice(0, 80)
23 | }
24 |
25 | return {
26 | id: thread.id,
27 | title,
28 | createdAt: thread.createdAt,
29 | updatedAt: thread.updatedAt,
30 | scheduledAt: thread.scheduledAt,
31 | publishedAt: thread.publishedAt,
32 | nbMessages: latestThreadData.messages.length
33 | }
34 | })
35 | return result;
36 | })
37 |
--------------------------------------------------------------------------------
/server/api/threads.post.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../prisma/db'
2 |
3 | type CreateThreadRequest = {
4 | messages: [{
5 | text: string,
6 | attachments: [{
7 | createdAt: Date
8 | updatedAt: Date
9 | location: string
10 | alt?: string
11 | }]
12 | }]
13 | }
14 |
15 | export default defineEventHandler(async (event: any) => {
16 | console.log(`POST /api/threads`)
17 |
18 | const threadData: CreateThreadRequest = await readBody(event)
19 | const now = new Date()
20 |
21 | const [thread] = await prisma.$transaction([
22 | prisma.thread.create({
23 | data: {
24 | createdAt: now,
25 | versions: {
26 | create: [{
27 | createdAt: now,
28 | data: threadData
29 | }]
30 | }
31 | },
32 | include: {
33 | versions: true
34 | }
35 | })])
36 | const result: any = { ...thread }
37 | if (thread && thread.versions) {
38 | const [latest] = thread.versions.slice(-1)
39 | result.latest = latest
40 | }
41 | return result;
42 | })
--------------------------------------------------------------------------------
/server/api/threads/[id].delete.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../prisma/db'
2 |
3 | export default defineEventHandler(async (event: any) => {
4 | const threadId = parseInt(event.context.params.id) as number
5 |
6 | console.log(`DELETE /api/threads/${threadId}`)
7 |
8 | await prisma.thread.delete({
9 | where: {
10 | id: threadId,
11 | },
12 | })
13 |
14 | return {}
15 | })
--------------------------------------------------------------------------------
/server/api/threads/[id].get.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../prisma/db'
2 |
3 | export default defineEventHandler(async (event: any) => {
4 | const threadId = parseInt(event.context.params.id) as number
5 |
6 | console.log(`GET /api/threads/${threadId}`)
7 |
8 | const thread = await prisma.thread.findFirst({
9 | where: {
10 | id: threadId
11 | },
12 | include: {
13 | versions: true
14 | }
15 | })
16 | const result: any = { ...thread }
17 | if (thread && thread.versions) {
18 | const [latest] = thread.versions.slice(-1)
19 | result.latest = latest
20 | }
21 | return result;
22 | })
--------------------------------------------------------------------------------
/server/api/threads/[id].post.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../../../prisma/db'
2 |
3 | type UpdateThreadRequest = {
4 | messages: [{
5 | text: string,
6 | attachments: [{
7 | createdAt: Date
8 | updatedAt: Date
9 | location: string
10 | alt?: string
11 | }]
12 | }]
13 | }
14 |
15 | export default defineEventHandler(async (event: any) => {
16 | const threadId = parseInt(event.context.params.id) as number
17 |
18 | console.log(`POST /api/threads/${threadId}`)
19 |
20 | const threadData: UpdateThreadRequest = await readBody(event)
21 | const now = new Date()
22 |
23 | const [thread] = await prisma.$transaction([
24 | prisma.thread.update({
25 | where: {
26 | id: threadId
27 | },
28 | data: {
29 | updatedAt: now,
30 | versions: {
31 | create: [{
32 | createdAt: now,
33 | data: threadData
34 | }]
35 | }
36 | },
37 | include: {
38 | versions: true
39 | }
40 | })])
41 | const result: any = { ...thread }
42 | if (thread && thread.versions) {
43 | const [latest] = thread.versions.slice(-1)
44 | result.latest = latest
45 | }
46 | return result;
47 | })
--------------------------------------------------------------------------------
/server/api/threads/[id]/publication.post.ts:
--------------------------------------------------------------------------------
1 | import { publish } from '~/services/publication-service';
2 | import { Thread } from '~/models/models';
3 | import { prisma } from '~/prisma/db';
4 |
5 | export default defineEventHandler(async (event: any) => {
6 | const threadId = parseInt(event.context.params.id) as number
7 |
8 | console.log(`POST /api/threads/${threadId}/publication`)
9 |
10 | try {
11 | const thread: Thread = await publish(threadId)
12 | return thread
13 | } catch (error) {
14 | return {
15 | status: 'error',
16 | data: error
17 | }
18 | }
19 | })
--------------------------------------------------------------------------------
/server/api/threads/[id]/schedule.delete.ts:
--------------------------------------------------------------------------------
1 | import { queue } from '~/utils/queue'
2 | import { prisma } from '~/prisma/db'
3 |
4 | export default defineEventHandler(async (event: any) => {
5 | const threadId = parseInt(event.context.params.id) as number
6 |
7 | console.log(`DELETE /api/threads/${threadId}/schedule`)
8 |
9 | const threadData = await prisma.thread.findFirst({ where: { id: threadId } })
10 | if (!threadData) {
11 | throw new Error(`Could not publish thread with ID ${threadId} because it does not exist.`)
12 | }
13 | await prisma.thread.update({
14 | where: { id: threadId },
15 | data: { scheduledAt: null }
16 | })
17 |
18 | const jobName = `thread-${threadId}`
19 | const jobs = await queue.getDelayed()
20 | const threadScheduleJob = jobs.find((job) => job.name === jobName)
21 | if (threadScheduleJob && threadScheduleJob.id) {
22 | await queue.remove(threadScheduleJob.id)
23 | }
24 |
25 | return
26 | })
--------------------------------------------------------------------------------
/server/api/threads/[id]/schedule.post.ts:
--------------------------------------------------------------------------------
1 | import { Thread } from '~/models/models'
2 | import { Worker } from 'bullmq'
3 | import { publish } from '~/services/publication-service'
4 | import { connection, queue } from '~/utils/queue'
5 | import { prisma } from '~/prisma/db'
6 |
7 | export default defineEventHandler(async (event: any) => {
8 | const threadId = parseInt(event.context.params.id) as number
9 | const data: any = await readBody(event)
10 |
11 | console.log(`POST /api/threads/${threadId}/schedule`)
12 |
13 | console.log("data:", data)
14 |
15 | console.log('data.scheduledAt', data.scheduledAt)
16 | const scheduledAt = Date.parse(data.scheduledAt)
17 | const delay = scheduledAt - Date.now()
18 |
19 | const threadData = await prisma.thread.findFirst({ where: { id: threadId } })
20 | if (!threadData) {
21 | throw new Error(`Could not publish thread with ID ${threadId} because it does not exist.`)
22 | }
23 | await prisma.thread.update({
24 | where: { id: threadId },
25 | data: { scheduledAt: data.scheduledAt }
26 | })
27 |
28 | await queue.add(`thread-${threadId}`, { threadId: threadId }, { delay });
29 |
30 | const worker = new Worker('thread-schedules', async (job) => {
31 | try {
32 | const thread: Thread = await publish(threadId)
33 | return thread
34 | } catch (error) {
35 | return {
36 | status: 'error',
37 | data: error
38 | }
39 | }
40 | }, { connection })
41 | worker.on('completed', job => {
42 | console.log(`Job ${job.id} has completed!`);
43 | });
44 |
45 | worker.on('failed', (job, err) => {
46 | console.log(`Job ${job?.id} has failed with ${err.message}`);
47 | });
48 | })
--------------------------------------------------------------------------------
/server/settings.get.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeAll } from "vitest"
2 | import { setup, fetch } from '@nuxt/test-utils'
3 | import { prisma } from '../prisma/db'
4 |
5 | async function insertIntoSettings(data: any): Promise {
6 | try {
7 | await prisma.settings.create({ data })
8 | console.log('Data inserted successfully');
9 | } catch (error) {
10 | console.error('Error inserting data into Settings:', error);
11 | }
12 | }
13 |
14 | describe("GET /api/settings", async () => {
15 |
16 | await setup({ browser: false })
17 |
18 | describe('when there is no data in database', () => {
19 |
20 | beforeAll(async () => {
21 | await prisma.settings.deleteMany()
22 | });
23 |
24 | it("should respond with status code 204", async () => {
25 | // when
26 | const response: Response = await fetch('/api/settings');
27 |
28 | // then
29 | expect(response.status).toBe(204)
30 | });
31 |
32 | it("should return nothing", async () => {
33 | // when
34 | const response: Response = await fetch('/api/settings');
35 |
36 | // then
37 | expect(response.body).toBe(null)
38 | })
39 |
40 | })
41 |
42 | describe('when there is data in database', () => {
43 |
44 | const data = {
45 | displayName: 'jbuget@threadr.app',
46 | avatarUrl: 'https://example.com/avatar.png',
47 | blueskyEnabled: true,
48 | blueskyUrl: 'https://bsky.social',
49 | blueskyIdentifier: 'my_bluesky_identifier',
50 | blueskyPassword: 'my_bluesky_password',
51 | mastodonEnabled: true,
52 | mastodonUrl: 'https://piaille.fr',
53 | mastodonAccessToken: 'my_mastodon_access_token',
54 | twitterEnabled: true,
55 | twitterConsumerKey: 'my_mastodon_access_token',
56 | twitterConsumerSecret: 'my_twitter_consumer_secret',
57 | twitterAccessToken: 'my_twitter_access_token',
58 | twitterAccessSecret: 'my_twitter_access_secret',
59 | }
60 |
61 | beforeAll(async () => {
62 | await prisma.settings.deleteMany()
63 | await insertIntoSettings(data)
64 | });
65 |
66 | it("should respond with status code 200", async () => {
67 | // when
68 | const response: Response = await fetch('/api/settings');
69 |
70 | // then
71 | expect(response.status).toBe(200)
72 | });
73 |
74 | it("should return a JSON object", async () => {
75 | // when
76 | const response: Response = await fetch('/api/settings');
77 |
78 | /// then
79 | const body = await response.json();
80 | expect(body).toEqual({
81 | display_name: data.displayName,
82 | avatar_url: data.avatarUrl,
83 | bluesky_enabled: data.blueskyEnabled,
84 | bluesky_url: data.blueskyUrl,
85 | bluesky_identifier: data.blueskyIdentifier,
86 | bluesky_password: data.blueskyPassword,
87 | mastodon_enabled: data.mastodonEnabled,
88 | mastodon_url: data.mastodonUrl,
89 | mastodon_access_token: data.mastodonAccessToken,
90 | twitter_enabled: data.twitterEnabled,
91 | twitter_consumer_key: data.twitterConsumerKey,
92 | twitter_consumer_secret: data.twitterConsumerSecret,
93 | twitter_access_token: data.twitterAccessToken,
94 | twitter_access_secret: data.twitterAccessSecret,
95 | })
96 | });
97 | })
98 |
99 | });
100 |
--------------------------------------------------------------------------------
/server/settings.patch.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach } from "vitest"
2 | import { $fetch, setup } from '@nuxt/test-utils'
3 | import { prisma } from '../prisma/db'
4 |
5 | async function insertIntoSettings(data: any): Promise {
6 | try {
7 | const settings = await prisma.settings.create({ data })
8 | console.log('Data inserted successfully');
9 | return settings
10 | } catch (error) {
11 | console.error('Error inserting data into Settings:', error);
12 | }
13 | }
14 |
15 | describe("PATCH /api/settings", async () => {
16 |
17 | await setup({ browser: false })
18 |
19 | describe('when there is data in database', () => {
20 |
21 | const beforeData = {
22 | displayName: 'before_display_name',
23 | avatarUrl: 'https://before.avatar.url',
24 | blueskyEnabled: false,
25 | blueskyUrl: 'https://before.bluesky.url',
26 | blueskyIdentifier: 'before_bluesky_identifier',
27 | blueskyPassword: 'before_bluesky_password',
28 | mastodonEnabled: false,
29 | mastodonUrl: 'https://before.mastodon.url',
30 | mastodonAccessToken: 'before_mastodon_access_token',
31 | twitterEnabled: false,
32 | twitterConsumerKey: 'before_mastodon_access_token',
33 | twitterConsumerSecret: 'before_twitter_consumer_secret',
34 | twitterAccessToken: 'before_twitter_access_token',
35 | twitterAccessSecret: 'before_twitter_access_secret',
36 | }
37 |
38 | let existingSettings: any
39 |
40 | const afterData = {
41 | displayName: 'after_display_name',
42 | avatarUrl: 'https://after.avatar.url',
43 | blueskyEnabled: true,
44 | blueskyUrl: 'https://after.bluesky.url',
45 | blueskyIdentifier: 'after_bluesky_identifier',
46 | blueskyPassword: 'after_bluesky_password',
47 | mastodonEnabled: true,
48 | mastodonUrl: 'https://after.mastodon.url',
49 | mastodonAccessToken: 'after_mastodon_access_token',
50 | twitterEnabled: true,
51 | twitterConsumerKey: 'after_mastodon_access_token',
52 | twitterConsumerSecret: 'after_twitter_consumer_secret',
53 | twitterAccessToken: 'after_twitter_access_token',
54 | twitterAccessSecret: 'after_twitter_access_secret',
55 | }
56 |
57 | const newBody = {
58 | display_name: afterData.displayName,
59 | avatar_url: afterData.avatarUrl,
60 | bluesky_enabled: afterData.blueskyEnabled,
61 | bluesky_url: afterData.blueskyUrl,
62 | bluesky_identifier: afterData.blueskyIdentifier,
63 | bluesky_password: afterData.blueskyPassword,
64 | mastodon_enabled: afterData.mastodonEnabled,
65 | mastodon_url: afterData.mastodonUrl,
66 | mastodon_access_token: afterData.mastodonAccessToken,
67 | twitter_enabled: afterData.twitterEnabled,
68 | twitter_consumer_key: afterData.twitterConsumerKey,
69 | twitter_consumer_secret: afterData.twitterConsumerSecret,
70 | twitter_access_token: afterData.twitterAccessToken,
71 | twitter_access_secret: afterData.twitterAccessSecret,
72 | }
73 |
74 | beforeEach(async () => {
75 | await prisma.settings.deleteMany()
76 | existingSettings = await insertIntoSettings(beforeData)
77 | });
78 |
79 | it.skip("should respond with status code 200", async () => {
80 | // when
81 | const response: Response = await $fetch('/api/settings', { method: 'PATCH', body: newBody });
82 |
83 | // then
84 | expect(response.status).toBe(200)
85 | })
86 |
87 | it.skip("should return a JSON object", async () => {
88 | // when
89 | const response: Response = await $fetch('/api/settings', { method: 'PATCH', body: newBody });
90 |
91 | // then
92 | const body = await response.json();
93 | expect(body).toEqual(newBody)
94 | });
95 |
96 | it("should update data in DB", async () => {
97 | // when
98 | await $fetch('/api/settings', { method: 'PATCH', body: { ...newBody } });
99 |
100 | // then
101 | const persistedSettings = await prisma.settings.findFirst()
102 | expect(persistedSettings).toMatchObject({
103 | id: existingSettings.id,
104 | createdAt: existingSettings.createdAt,
105 | ...afterData
106 | })
107 | });
108 |
109 | })
110 |
111 | });
112 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../.nuxt/tsconfig.server.json"
3 | }
4 |
--------------------------------------------------------------------------------
/services/bluesky-url-facets-extractor.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest";
2 | import buildUrlFacets from "./bluesky-url-facets-extractor";
3 |
4 | describe("Bluesky build url facets from text", () => {
5 | it("should return empty array if no url in text", () => {
6 | const text = "This is a text without url";
7 | const urls = buildUrlFacets(text);
8 | expect(urls).toEqual([]);
9 | });
10 |
11 | it("should find urls in text", () => {
12 | const text =
13 | "\u2728 example mentioning @atproto.com to share the URL \ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68 https://en.wikipedia.org/wiki/CBOR.";
14 | const urls = buildUrlFacets(text);
15 | expect(urls).toEqual([
16 | {
17 | index: {
18 | byteStart: 74,
19 | byteEnd: 108,
20 | },
21 | features: [
22 | {
23 | $type: "app.bsky.richtext.facet#link",
24 | uri: "https://en.wikipedia.org/wiki/CBOR",
25 | },
26 | ],
27 | },
28 | ]);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/services/bluesky-url-facets-extractor.ts:
--------------------------------------------------------------------------------
1 | const buildUrlFacets = (text: string) => {
2 | const urlRegex =
3 | /http(s)?:\/\/.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+~#?&//=]*)/g;
4 | const urlFacets = [];
5 |
6 | const encoder = new TextEncoder();
7 |
8 | let match;
9 | while ((match = urlRegex.exec(text)) !== null) {
10 | const startIndex = encoder.encode(text.substring(0, match.index)).length;
11 | const url = match[0];
12 | const endIndex = startIndex + encoder.encode(url).length;
13 |
14 | urlFacets.push({
15 | index: {
16 | byteStart: startIndex,
17 | byteEnd: endIndex,
18 | },
19 | features: [
20 | {
21 | $type: "app.bsky.richtext.facet#link",
22 | uri: url,
23 | },
24 | ],
25 | });
26 | }
27 |
28 | return urlFacets;
29 | };
30 |
31 | export default buildUrlFacets;
32 |
--------------------------------------------------------------------------------
/services/publication-service.ts:
--------------------------------------------------------------------------------
1 | import { Thread, Message } from "~/models/models";
2 | import { createRestAPIClient, mastodon } from "masto";
3 | import AtProtocole from "@atproto/api";
4 | import { ReplyRef } from "@atproto/api/dist/client/types/app/bsky/feed/post";
5 | import {
6 | SendTweetV2Params,
7 | TweetV2PostTweetResult,
8 | TwitterApi,
9 | } from "twitter-api-v2";
10 | import { prisma } from "~/prisma/db";
11 | import buildUrlFacets from "./bluesky-url-facets-extractor";
12 | import { Settings } from "@prisma/client";
13 |
14 | // Publication on Bluesky
15 |
16 | interface RecordRef {
17 | uri: string;
18 | cid: string;
19 | }
20 |
21 | async function getBlueskyClient(settings: Settings): Promise {
22 | console.log("Connect to Bluesky…")
23 | let blueskyClient: AtProtocole.BskyAgent
24 | blueskyClient = new AtProtocole.BskyAgent({
25 | service: settings.blueskyUrl,
26 | })
27 | await blueskyClient.login({
28 | identifier: settings.blueskyIdentifier,
29 | password: settings.blueskyPassword,
30 | })
31 | console.log("Connection to Bluesky acquired.")
32 | return blueskyClient
33 | }
34 |
35 | async function postMessageOnBluesky(
36 | blueskyClient: AtProtocole.BskyAgent,
37 | message: Message,
38 | reply: ReplyRef | null
39 | ): Promise {
40 | try {
41 | const record: any = {};
42 | record.text = message.text;
43 | record.facets = buildUrlFacets(message.text);
44 |
45 | if (message.attachments && message.attachments.length > 0) {
46 | let embed: any;
47 | embed = {
48 | $type: "app.bsky.embed.images",
49 | images: [],
50 | };
51 | for (const file of message.attachments) {
52 | const mediaFile = await fetch(file.location);
53 | const mediaData = await mediaFile.arrayBuffer();
54 | const mediaResponse = await blueskyClient.uploadBlob(
55 | Buffer.from(mediaData),
56 | { encoding: file.mimetype }
57 | );
58 | embed.images.push({ image: mediaResponse.data.blob, alt: file.alt });
59 | }
60 | record.embed = embed;
61 | }
62 |
63 | if (reply) {
64 | record.reply = reply;
65 | }
66 |
67 | return blueskyClient.post(record);
68 | } catch (error) {
69 | console.error(error);
70 | throw error;
71 | }
72 | }
73 |
74 | async function postMessagesOnBluesky(blueskyClient: AtProtocole.BskyAgent, messages: Message[]): Promise {
75 | try {
76 | let reply: ReplyRef | null = null;
77 |
78 | for (const message of messages) {
79 | console.log("Publish message on Bluesky");
80 | const recordRef: RecordRef = await postMessageOnBluesky(blueskyClient, message, reply);
81 | reply = {
82 | parent: {
83 | cid: recordRef.cid,
84 | uri: recordRef.uri,
85 | },
86 | root: {
87 | cid: recordRef.cid,
88 | uri: recordRef.uri,
89 | },
90 | };
91 | console.log("Message published on Bluesky.");
92 | }
93 | } catch (error) {
94 | console.error(error);
95 | throw error;
96 | }
97 | }
98 |
99 | // Publication on Mastodon
100 |
101 | async function getMastodonClient(settings: Settings): Promise {
102 | console.log("Connect to Mastodon…")
103 | let mastodonClient: mastodon.rest.Client
104 | mastodonClient = createRestAPIClient({
105 | url: settings.mastodonUrl || 'undefined_url',
106 | accessToken: settings.mastodonAccessToken || 'undefined_access_token',
107 | })
108 | console.log("Connection to Mastodon acquired.")
109 | return mastodonClient
110 | }
111 |
112 | async function postMessageOnMastodon(
113 | mastodonClient: mastodon.rest.Client,
114 | message: Message,
115 | inReplyToId: string | null
116 | ): Promise {
117 | const mediaIds: string[] = [];
118 | if (message.attachments) {
119 | for (const attachment of message.attachments) {
120 | const remoteFile = await fetch(attachment.location);
121 | const media = await mastodonClient.v2.media.create({
122 | file: await remoteFile.blob(),
123 | description: attachment.alt,
124 | });
125 | mediaIds.push(media.id);
126 | }
127 | }
128 |
129 | return mastodonClient.v1.statuses.create({
130 | status: message.text,
131 | visibility: "public",
132 | mediaIds,
133 | inReplyToId,
134 | });
135 | }
136 |
137 | async function postMessagesOnMastodon(mastodonClient: mastodon.rest.Client, messages: Message[]): Promise {
138 | let inReplyToId: string | null = null;
139 |
140 | for (const message of messages) {
141 | console.log("Publish message on Mastodon…");
142 | const status: mastodon.v1.Status = await postMessageOnMastodon(
143 | mastodonClient,
144 | message,
145 | inReplyToId
146 | );
147 | inReplyToId = status.id;
148 | console.log("Message published on Mastodon.");
149 | }
150 | }
151 |
152 | // Publication on Twitter
153 |
154 | async function getTwitterClient(settings: Settings): Promise {
155 | console.log("Connect to Twitter…")
156 | let twitterClient: TwitterApi
157 | twitterClient = new TwitterApi({
158 | appKey: settings.twitterConsumerKey || 'twitter_consumer_key',
159 | appSecret: settings.twitterConsumerSecret || 'twitter_consumer_secret',
160 | accessToken: settings.twitterAccessToken || 'twitter_access_token',
161 | accessSecret: settings.twitterAccessSecret || 'twitter_access_secret',
162 | });
163 | console.log("Connection to Twitter acquired.")
164 | return twitterClient
165 | }
166 |
167 | async function postMessageOnTwitter(
168 | twitterClient: TwitterApi,
169 | message: Message,
170 | reply: TweetV2PostTweetResult | null
171 | ): Promise {
172 | try {
173 | const tweet: SendTweetV2Params = {};
174 | if (message.text) {
175 | tweet.text = message.text;
176 | }
177 | if (message.attachments && message.attachments.length > 0) {
178 | const mediaIds = [];
179 | for (const file of message.attachments) {
180 | const mediaResponse = await fetch(file.location);
181 | const mediaData = await mediaResponse.arrayBuffer();
182 | const mediaId = await twitterClient.v1.uploadMedia(
183 | Buffer.from(mediaData),
184 | { mimeType: file.mimetype }
185 | );
186 | mediaIds.push(mediaId);
187 | }
188 | tweet.media = { media_ids: mediaIds };
189 | }
190 | if (reply && reply.data) {
191 | tweet.reply = { in_reply_to_tweet_id: reply.data.id };
192 | }
193 | return twitterClient.v2.tweet(tweet);
194 | } catch (error) {
195 | console.error(error);
196 | throw error;
197 | }
198 | }
199 |
200 | async function postMessagesOnTwitter(twitterClient: TwitterApi, messages: Message[]): Promise {
201 | let reply: TweetV2PostTweetResult | null = null;
202 |
203 | for (const message of messages) {
204 | console.log("Publish message on Twitter");
205 | reply = await postMessageOnTwitter(twitterClient, message, reply);
206 | console.log("Message published on twitter.");
207 | }
208 | }
209 |
210 | async function postMessages(messages: Message[]): Promise {
211 | const platforms: string[] = [];
212 |
213 | const settings = await prisma.settings.findFirst()
214 |
215 | if (settings) {
216 | if (settings.blueskyEnabled) {
217 | const blueskyClient = await getBlueskyClient(settings)
218 | console.log("Publish messages on Bluesky");
219 | await postMessagesOnBluesky(blueskyClient, messages);
220 | platforms.push("Bluesky");
221 | console.log("Messages published on Bluesky.");
222 | }
223 |
224 | if (settings.mastodonEnabled) {
225 | const mastodonClient = await getMastodonClient(settings)
226 | console.log("Publish messages on Mastodon…");
227 | await postMessagesOnMastodon(mastodonClient, messages);
228 | platforms.push("Mastodon");
229 | console.log("Messages published on Mastodon.");
230 | }
231 |
232 | if (settings.twitterEnabled) {
233 | const twitterClient = await getTwitterClient(settings)
234 | console.log("Publish messages on Twitter…");
235 | await postMessagesOnTwitter(twitterClient, messages);
236 | platforms.push("Twitter");
237 | console.log("Messages published on Twitter.");
238 | }
239 |
240 | }
241 | return platforms;
242 | }
243 |
244 | export async function publish(threadId: number): Promise {
245 | const threadData = await prisma.thread.findFirst({
246 | where: {
247 | id: threadId,
248 | },
249 | include: {
250 | versions: true,
251 | },
252 | });
253 |
254 | if (!threadData) {
255 | throw new Error(
256 | `Could not publish thread with ID ${threadId} because it does not exist.`
257 | );
258 | }
259 |
260 | const [latestVersion] = threadData.versions.slice(-1);
261 | const latestVersionData: any = latestVersion.data;
262 |
263 | console.log("Publish thread…");
264 | const platforms: string[] = await postMessages(latestVersionData.messages);
265 |
266 | await prisma.thread.update({
267 | where: {
268 | id: threadId,
269 | },
270 | data: {
271 | publishedAt: new Date(),
272 | },
273 | });
274 |
275 | let report = "Thread published";
276 | if (platforms.length === 1) {
277 | report += " on " + platforms[0];
278 | }
279 | if (platforms.length > 1) {
280 | const last = platforms.pop();
281 | report += " on " + platforms.join(", ") + " and " + last;
282 | }
283 | console.log(`${report} 🎉 !`);
284 | return {
285 | id: threadData.id,
286 | messages: latestVersionData.messages,
287 | };
288 | }
289 |
--------------------------------------------------------------------------------
/threadr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbuget/threadr-app/d2a6229168f72bc0e7d7a6b9e4396b5e8343b526/threadr.png
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://nuxt.com/docs/guide/concepts/typescript
3 | "extends": "./.nuxt/tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------
/utils/queue.ts:
--------------------------------------------------------------------------------
1 | import IORedis from 'ioredis';
2 | import { Queue } from 'bullmq';
3 |
4 | const connection = new IORedis(process.env.REDIS_URL as string, { maxRetriesPerRequest: null });
5 |
6 | const queue = new Queue('thread-schedules', { connection })
7 |
8 | export { connection, queue }
--------------------------------------------------------------------------------
/utils/test-utils.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from '../prisma/db'
2 |
3 | async function clearData() {
4 | try {
5 | await prisma.thread.deleteMany();
6 | await prisma.version.deleteMany();
7 | await prisma.settings.deleteMany();
8 | } catch (error) {
9 | console.error("Erreur lors de l'exécution du script SQL:", error);
10 | }
11 | }
12 |
13 | export { clearData };
--------------------------------------------------------------------------------
/utils/utils.ts:
--------------------------------------------------------------------------------
1 | export function generateUniqueKey(prefix: string, suffix?: string | number) {
2 | return `${prefix}_${Date.now()}_${generateRandomString()}`
3 | }
4 |
5 | export function generateRandomString() {
6 | return Math.random().toString(36).slice(2, 7);
7 | }
--------------------------------------------------------------------------------