diff --git a/app/components/rewrite-card.vue b/app/components/rewrite-card.vue
new file mode 100644
index 0000000..e85de11
--- /dev/null
+++ b/app/components/rewrite-card.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
🔄 Re-write activity
+
+ Premium only
+
+
+
+
+
+ Activity URL
+
+ Paste your Strava activity URL to rewrite it.
+
+
+
+
+
+ {{ errors?.errors[0]?.message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/error.vue b/app/error.vue
index c5a4122..83c56b8 100644
--- a/app/error.vue
+++ b/app/error.vue
@@ -3,41 +3,39 @@ const error = useError();
-
-
-
-
-
- {{ error?.statusCode }}
-
-
+
+
+
+
+ {{ error?.statusCode }}
+
+
-
- Uh oh... Something went super wrong.
-
-
-
- {{ error?.message }}
-
+
+ Uh oh... Something went super wrong.
-
-
-
-
-
-
-
+
+ {{ error?.message }}
+
+
-
-
+
+
+
+
+
+
+
+
+
diff --git a/app/layouts/default.vue b/app/layouts/default.vue
index bdb97bd..3eb76ab 100644
--- a/app/layouts/default.vue
+++ b/app/layouts/default.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 5449a5f..d53890f 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -110,6 +110,8 @@ const saveOp = watchPausable(
+
+
🪪 Your connected Strava account
diff --git a/package-lock.json b/package-lock.json
index dc6b2e1..5654eeb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@nuxt/ui": "3.0.2",
"@vee-validate/nuxt": "^4.15.0",
"@vueuse/nuxt": "^13.0.0",
+ "destr": "^2.0.5",
"drizzle-orm": "^0.41.0",
"nuxt": "^3.16.2",
"nuxt-auth-utils": "0.5.18",
diff --git a/package.json b/package.json
index 57d1f27..637e8b8 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@nuxt/ui": "3.0.2",
"@vee-validate/nuxt": "^4.15.0",
"@vueuse/nuxt": "^13.0.0",
+ "destr": "^2.0.5",
"drizzle-orm": "^0.41.0",
"nuxt": "^3.16.2",
"nuxt-auth-utils": "0.5.18",
diff --git a/server/api/rewrite.post.ts b/server/api/rewrite.post.ts
new file mode 100644
index 0000000..bd4fba2
--- /dev/null
+++ b/server/api/rewrite.post.ts
@@ -0,0 +1,53 @@
+export default defineEventHandler(async (event) => {
+ const session = await requireUserSession(event);
+
+ const db = useDrizzle();
+
+ const query = getQuery(event);
+
+ const user = await db.query.users.findFirst({
+ where: (f, o) => o.eq(f.id, session.user.id),
+ with: {
+ preferences: true,
+ },
+ });
+
+ if (!user?.premium) {
+ throw createError({
+ statusCode: 400,
+ message: "Premium membership required.",
+ });
+ }
+
+ const activityId = (query.activity as string).replace(
+ /https:\/\/(www\.)?strava\.com\/activities\//,
+ "",
+ );
+
+ const strava = await useStrava(session.user.id);
+
+ const activity = await strava!(`/activities/${activityId}`);
+
+ const [aiError, stravaRequestBody] = await createActivityContent(
+ activity,
+ user!,
+ );
+ if (aiError) {
+ throw createError({
+ statusCode: 500,
+ message: `OPENAI API: ${aiError.message}`,
+ });
+ }
+
+ await strava!(`activities/${activityId}`, {
+ method: "PUT",
+ body: stravaRequestBody,
+ }).catch((error) => {
+ throw createError({
+ statusCode: 500,
+ message: `Strava API: ${error.message}`,
+ });
+ });
+
+ sendNoContent(event);
+});
diff --git a/server/auth.d.ts b/server/auth.d.ts
index d601edd..694c453 100644
--- a/server/auth.d.ts
+++ b/server/auth.d.ts
@@ -16,6 +16,7 @@ declare module "#auth-utils" {
sex: string;
weight: number;
avatar: string;
+ premium: boolean;
};
}
diff --git a/server/database/migrations/0001_smooth_jazinda.sql b/server/database/migrations/0001_smooth_jazinda.sql
new file mode 100644
index 0000000..f4b50d6
--- /dev/null
+++ b/server/database/migrations/0001_smooth_jazinda.sql
@@ -0,0 +1 @@
+ALTER TABLE "users" ADD COLUMN "premium" boolean;
\ No newline at end of file
diff --git a/server/database/migrations/0002_many_marauders.sql b/server/database/migrations/0002_many_marauders.sql
new file mode 100644
index 0000000..1e7cb0c
--- /dev/null
+++ b/server/database/migrations/0002_many_marauders.sql
@@ -0,0 +1 @@
+ALTER TABLE "users" ALTER COLUMN "premium" SET NOT NULL;
\ No newline at end of file
diff --git a/server/database/migrations/meta/0001_snapshot.json b/server/database/migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..05b96c6
--- /dev/null
+++ b/server/database/migrations/meta/0001_snapshot.json
@@ -0,0 +1,228 @@
+{
+ "id": "912c6e36-57b1-4e7f-a29b-586b187b1c32",
+ "prevId": "c8519a52-b999-48f3-b532-42a5678e3905",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.preferences": {
+ "name": "preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "preferences_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "preferences_user_id_users_id_fk": {
+ "name": "preferences_user_id_users_id_fk",
+ "tableFrom": "preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "preferences_user_id_unique": {
+ "name": "preferences_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tokens": {
+ "name": "tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "tokens_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tokens_user_id_users_id_fk": {
+ "name": "tokens_user_id_users_id_fk",
+ "tableFrom": "tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tokens_user_id_unique": {
+ "name": "tokens_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar": {
+ "name": "avatar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "city": {
+ "name": "city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sex": {
+ "name": "sex",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "premium": {
+ "name": "premium",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/database/migrations/meta/0002_snapshot.json b/server/database/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..de124da
--- /dev/null
+++ b/server/database/migrations/meta/0002_snapshot.json
@@ -0,0 +1,228 @@
+{
+ "id": "7b607167-551b-4aa3-8305-afe3c4d72ace",
+ "prevId": "912c6e36-57b1-4e7f-a29b-586b187b1c32",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.preferences": {
+ "name": "preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "preferences_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "preferences_user_id_users_id_fk": {
+ "name": "preferences_user_id_users_id_fk",
+ "tableFrom": "preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "preferences_user_id_unique": {
+ "name": "preferences_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tokens": {
+ "name": "tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "tokens_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tokens_user_id_users_id_fk": {
+ "name": "tokens_user_id_users_id_fk",
+ "tableFrom": "tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tokens_user_id_unique": {
+ "name": "tokens_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar": {
+ "name": "avatar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "city": {
+ "name": "city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sex": {
+ "name": "sex",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "premium": {
+ "name": "premium",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json
index ca577c0..d4d9036 100644
--- a/server/database/migrations/meta/_journal.json
+++ b/server/database/migrations/meta/_journal.json
@@ -8,6 +8,20 @@
"when": 1745335305323,
"tag": "0000_slim_blonde_phantom",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1747547391071,
+ "tag": "0001_smooth_jazinda",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1747564780270,
+ "tag": "0002_many_marauders",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/server/database/schema.ts b/server/database/schema.ts
index 9dabc0c..6fed1d8 100644
--- a/server/database/schema.ts
+++ b/server/database/schema.ts
@@ -6,6 +6,7 @@ import {
numeric,
timestamp,
jsonb,
+ boolean,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
@@ -15,6 +16,9 @@ export const users = pgTable("users", {
city: text("city"),
country: text("country"),
sex: text("sex"),
+ premium: boolean("premium")
+ .notNull()
+ .$defaultFn(() => false),
weight: numeric("weight", {
mode: "number",
}),
diff --git a/server/routes/auth/strava.ts b/server/routes/auth/strava.ts
index 1f37204..a054dda 100644
--- a/server/routes/auth/strava.ts
+++ b/server/routes/auth/strava.ts
@@ -32,6 +32,7 @@ export default defineOAuthStravaEventHandler({
sex: auth.user.sex,
weight: auth.user.weight,
avatar: auth.user.profile,
+ premium: false,
};
const db = useDrizzle();
@@ -41,7 +42,7 @@ export default defineOAuthStravaEventHandler({
.values(userPayload)
.onConflictDoUpdate({
target: tables.users.id,
- set: omit(userPayload, ["id"]),
+ set: omit(userPayload, ["id", "premium"]),
})
.returning();
@@ -77,7 +78,7 @@ export default defineOAuthStravaEventHandler({
.onConflictDoNothing();
await setUserSession(event, {
- user: userPayload,
+ user: { ...userPayload, premium: user.premium },
});
sendRedirect(event, "/");
diff --git a/server/routes/webhooks/strava/activity-create.post.ts b/server/routes/webhooks/strava/activity-create.post.ts
index 0a9d039..7eb93f2 100644
--- a/server/routes/webhooks/strava/activity-create.post.ts
+++ b/server/routes/webhooks/strava/activity-create.post.ts
@@ -20,7 +20,10 @@ export default defineEventHandler(async (event) => {
const activity = await strava!(`/activities/${body.object_id}`);
- const [aiError, aiResponse] = await createActivityContent(activity, user);
+ const [aiError, stravaRequestBody] = await createActivityContent(
+ activity,
+ user,
+ );
if (aiError) {
throw createError({
statusCode: 500,
@@ -28,20 +31,6 @@ export default defineEventHandler(async (event) => {
});
}
- const responseObject = JSON.parse(
- get(aiResponse, "output.0.content.0.text"),
- ) as {
- title: string;
- description: string;
- };
-
- const promo = "Written by https://ghostwriter.rocks 👻";
-
- const stravaRequestBody = {
- name: responseObject.title,
- description: [responseObject.description, promo].join("\n"),
- };
-
await strava!(`activities/${body.object_id}`, {
method: "PUT",
body: stravaRequestBody,
diff --git a/server/utils/create-content.ts b/server/utils/create-content.ts
index b696a27..b53d031 100644
--- a/server/utils/create-content.ts
+++ b/server/utils/create-content.ts
@@ -1,6 +1,9 @@
-import { chain, draw, omit } from "radash";
+import { chain, draw, get, omit, tryit } from "radash";
+import { safeDestr } from "destr";
import { User } from "./drizzle";
+const promo = "Written by https://ghostwriter.rocks 👻";
+
type Activity = Record;
const movingActivityTypes = [
@@ -116,7 +119,13 @@ export const createActivityContent = async (
"snarky",
]);
- const length = draw(["short", "short", "short", "medium", "a-little-more-than-medium"]);
+ const length = draw([
+ "short",
+ "short",
+ "short",
+ "medium",
+ "a-little-more-than-medium",
+ ]);
const prompt = `
Generate a short title and a ${length}-lengthed description for my strava activity. Use my preferred language and unit system.
@@ -168,5 +177,17 @@ export const createActivityContent = async (
},
});
- return [aiError, aiResponse] as const;
+ const [parseError, responseObject] = tryit(
+ chain(
+ (r) => get(r, "output.0.content.0.text"),
+ (r) => safeDestr<{ title: string; description: string }>(r),
+ ),
+ )(aiResponse);
+
+ const stravaRequestBody = {
+ name: responseObject!.title,
+ description: [responseObject!.description, promo].join("\n"),
+ };
+
+ return [aiError || parseError, stravaRequestBody] as const;
};