Skip to content

Commit

Permalink
feat: Add strava importer
Browse files Browse the repository at this point in the history
  • Loading branch information
thomas-lebeau committed Jan 13, 2024
1 parent 522b7fd commit c857773
Show file tree
Hide file tree
Showing 25 changed files with 653 additions and 91 deletions.
8 changes: 7 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{
"extends": ["eslint:recommended", "next/core-web-vitals"]
"extends": ["eslint:recommended", "next/core-web-vitals"],
"env": {
"es2020": true
},
"rules": {
"no-console": "warn"
}
}
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"postcss": "8.4.33",
"prettier": "^3.1.0",
"prisma": "^5.7.1",
"type-testing": "^0.2.0",
"typescript": "^5.3.3"
}
}
11 changes: 9 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ generator client {
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
// url = env("DATABASE_SHADOW_URL")
relationMode = "prisma"
}

Expand Down Expand Up @@ -109,10 +110,11 @@ model CimToComarca {

model Activity {
id String @id @default(cuid())
stravaId String
userId String
originId String? @db.Text
originType EnumOriginType?
name String
type String // TODO: rename sportType?
sportType String?
startDate DateTime
summaryPolyline String @db.Text
createdAt DateTime @default(now())
Expand All @@ -136,3 +138,8 @@ model CimToActivity {
@@index([cimId])
@@index([activityId])
}

enum EnumOriginType {
STRAVA
GPX
}
33 changes: 33 additions & 0 deletions src/app/api/activities/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getActivities } from "@/lib/db/activities";
import getServerSession from "@/lib/get-server-session";
import { NextRequest, NextResponse } from "next/server";
import { serializeError } from "serialize-error";
import { z } from "zod";

const originTypeQueryParamSchema = z
.union([z.literal("STRAVA"), z.literal("GPX")])
.nullable();

export async function GET(req: NextRequest) {
try {
const session = await getServerSession();

if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const originType = originTypeQueryParamSchema.safeParse(
new URL(req.url).searchParams.get("originType")
);

if (!originType.success) {
return NextResponse.json(originType.error.issues, { status: 422 });
}

const data = await getActivities(session.user.id, originType.data);

return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(serializeError(error), { status: 500 });
}
}
83 changes: 83 additions & 0 deletions src/app/api/ascents/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

import serverTimings from "@/lib/server-timings";
import getServerSession from "@/lib/get-server-session";
import { serializeError } from "serialize-error";
import { addAscent, deleteAscent } from "@/lib/db/ascent";
import { activitySchema } from "@/lib/db/activities";

const routeContextSchema = z.object({
params: z.object({
id: z.string(),
}),
});

const bodySchema = z.array(
activitySchema
.omit({
id: true,
userId: true,
createdAt: true,
updatedAt: true,
})
.extend({
startDate: z.string().transform((date) => new Date(date)),
})
);

async function handler(
req: NextRequest,
context: z.infer<typeof routeContextSchema>
) {
try {
const safeContext = routeContextSchema.safeParse(context);

if (!safeContext.success) {
return NextResponse.json(safeContext.error.issues, { status: 422 });
}

const id = safeContext.data.params.id;
const serverTiming = new serverTimings();
const session = await getServerSession();

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401, headers: serverTiming.headers() }
);
}

serverTiming.start("db");

let data;

if (req.method === "PUT") {
const safeBody = bodySchema.safeParse(await req.json());

if (!safeBody.success) {
return NextResponse.json(safeBody.error.issues, { status: 422 });
}

data = await addAscent(session.user.id, id, safeBody.data);
} else if (req.method === "DELETE") {
data = await deleteAscent(session.user.id, id);
} else {
return NextResponse.json(
{ error: "Method not allowed" },
{ status: 405 }
);
}

serverTiming.stop("db");

return NextResponse.json(data, {
status: 200,
headers: serverTiming.headers(),
});
} catch (error) {
return NextResponse.json(serializeError(error), { status: 500 });
}
}
export const PUT = handler;
export const DELETE = handler;
20 changes: 20 additions & 0 deletions src/app/api/ascents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getAscents } from "@/lib/db/ascent";
import getServerSession from "@/lib/get-server-session";
import { NextResponse } from "next/server";
import { serializeError } from "serialize-error";

export async function GET() {
try {
const session = await getServerSession();

if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const data = await getAscents(session.user.id);

return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(serializeError(error), { status: 500 });
}
}
6 changes: 3 additions & 3 deletions src/app/api/cims/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async function handler(
return NextResponse.json(safeContext.error.issues, { status: 422 });
}

const id = safeContext.data.params.id;
const cimId = safeContext.data.params.id;
const serverTiming = new serverTimings();
const session = await getServerSession();

Expand All @@ -41,14 +41,14 @@ async function handler(
if (req.method === "PUT") {
data = await prisma.cimToUser.create({
data: {
cimId: id,
cimId: cimId,
userId: session.user.id,
},
});
} else {
data = await prisma.cimToUser.deleteMany({
where: {
cimId: id,
cimId: cimId,
userId: session.user.id,
},
});
Expand Down
13 changes: 13 additions & 0 deletions src/app/api/cims/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getCims } from "@/lib/db/cims";
import { NextResponse } from "next/server";
import { serializeError } from "serialize-error";

export async function GET() {
try {
const data = await getCims();

return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(serializeError(error), { status: 500 });
}
}
21 changes: 19 additions & 2 deletions src/app/api/strava/activities/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ const routeContextSchema = z.object({
}),
});

const urlSearchParamsSchema = z.object({
since: z
.string()
.transform((date) => z.date().parse(new Date(date))?.getTime() / 1000)
.optional(),
});

export async function GET(
req: NextRequest,
context: z.infer<typeof routeContextSchema>
Expand Down Expand Up @@ -52,8 +59,18 @@ export async function GET(

const pageId = safeContext.data.params.id ?? 1;

const safeUrlSearchParams = urlSearchParamsSchema.safeParse(
Object.fromEntries(new URL(req.url).searchParams.entries())
);

if (!safeUrlSearchParams.success) {
return NextResponse.json(safeUrlSearchParams.error.issues, {
status: 422,
});
}

const res = await fetch(
`https://www.strava.com/api/v3/athlete/activities?page=${pageId}`,
`https://www.strava.com/api/v3/athlete/activities?page=${pageId}&after=${safeUrlSearchParams.data.since}`,
{
headers: {
Authorization: `Bearer ${access_token}`,
Expand All @@ -65,7 +82,7 @@ export async function GET(
const safeActivity = stravaActivitySchema.array().safeParse(data);

if (!safeActivity.success) {
return NextResponse.json(safeActivity.error.issues, { status: 422 });
return NextResponse.json(safeActivity.error.issues, { status: 422 }); //TODO: return [] or different error code
}

serverTiming.stop("get");
Expand Down
22 changes: 22 additions & 0 deletions src/app/components/queries/use-activities-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { OriginType, activitySchema } from "@/lib/db/activities";
import { useQuery } from "@tanstack/react-query";

export function useActivitiesQuery(
{
originType,
}: {
originType: OriginType;
} = { originType: null }
) {
const { data, error, isFetching } = useQuery({
queryKey: ["activities", originType],
queryFn: () =>
fetch("/api/activities" + `?originType=${originType}`)
.then((res) => res.json())
.then((data) => activitySchema.array().parse(data)),
initialData: [],
refetchOnMount: false,
});

return { data, error, isFetching };
}
35 changes: 35 additions & 0 deletions src/app/components/queries/use-ascents-mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Activity } from "@/lib/db/activities";
import {
DefaultError,
useMutation,
useQueryClient,
} from "@tanstack/react-query";

type Variables = {
cimId: string;
action: "ADD" | "REMOVE";
activities?: Omit<Activity, "id" | "userId" | "createdAt" | "updatedAt">[];
};

export function useAscentMutation() {
const queryClient = useQueryClient();

const { isPending, variables, mutate } = useMutation<
unknown,
DefaultError,
Variables
>({
mutationKey: ["ascents"],
mutationFn: ({ action, cimId, activities = [] }) =>
fetch(`/api/ascents/${cimId}`, {
method: action === "ADD" ? "PUT" : action === "REMOVE" ? "DELETE" : "",
body: JSON.stringify(activities),
}).then((res) => res.json()),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ["ascents"],
}),
});

return { isPending, variables, mutate };
}
15 changes: 15 additions & 0 deletions src/app/components/queries/use-ascents-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ascentSchema } from "@/lib/db/ascent";
import { useQuery } from "@tanstack/react-query";

export function useAscentsQuery() {
const { data, error, isFetching } = useQuery({
queryKey: ["ascents"],
queryFn: () =>
fetch("/api/ascents")
.then((res) => res.json())
.then((data) => ascentSchema.array().parse(data)),
initialData: [],
});

return { data, error, isFetching };
}
15 changes: 15 additions & 0 deletions src/app/components/queries/use-cims-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { cimSchema } from "@/lib/db/cims";
import { useQuery } from "@tanstack/react-query";

export function useCimsQuery() {
const { data, error, isFetching } = useQuery({
queryKey: ["cims"],
queryFn: () =>
fetch("/api/cims")
.then((res) => res.json())
.then((data) => cimSchema.array().parse(data)),
initialData: [],
});

return { data, error, isFetching };
}
Loading

0 comments on commit c857773

Please sign in to comment.