diff --git a/.env.example b/.env.example index a303c20..943a364 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ SUPABASE_URL="https://yourhost.supabase.co" SUPABASE_KEY="your-secret-key" +SUPABASE_SERVICE_KEY="your-service-key" +NUXT_SPOTIFY_CLIENT_ID="your-client-id" +NUXT_SPOTIFY_CLIENT_SECRET="your-client-secret" \ No newline at end of file diff --git a/server/api/v1/playlist/[uid].get.ts b/server/api/v1/playlist/[uid].get.ts index 3a13d4c..b9be938 100644 --- a/server/api/v1/playlist/[uid].get.ts +++ b/server/api/v1/playlist/[uid].get.ts @@ -1,10 +1,18 @@ -import {serverSupabaseUser} from "#supabase/server"; +import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server"; + +/** + * Endpoint to get a playlist by id + * @returns {Object} - Playlist object + * @throws {400} - Invalid playlistId + * @throws {401} - Unauthenticated + * @throws {500} - Internal Server Error + */ export default defineEventHandler(async (event) => { const playlistId = getRouterParam(event, 'uid') // check regex playlistId - if (!isValidSpotifyID(playlistId)) { + if (!playlistId || !isValidSpotifyID(playlistId!)) { setResponseStatus(event, 400); return {error: 'invalid playlistId'}; } @@ -16,6 +24,13 @@ export default defineEventHandler(async (event) => { return {error: 'unauthenticated'}; } + const client = serverSupabaseServiceRole(event); + const {data, error} = await client.from('playlists').select('*').eq('id', playlistId).single(); + + if (error) { + setResponseStatus(event, 500); + return {error: error.message}; + } - return user; + return data; }) \ No newline at end of file diff --git a/server/api/v1/playlist/index.get.ts b/server/api/v1/playlist/index.get.ts new file mode 100644 index 0000000..1a23cb1 --- /dev/null +++ b/server/api/v1/playlist/index.get.ts @@ -0,0 +1,27 @@ +import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server"; + +/** + * Endpoint to get all playlists + * @throws {401} - Unauthenticated + * @throws {500} - Internal Server Error + * @returns {Array} - Array of playlists + */ +export default defineEventHandler(async (event) => { + + // Require user to be authenticated + const user = await serverSupabaseUser(event); + if (!user?.id) { + setResponseStatus(event, 401); + return {error: 'unauthenticated'}; + } + + const client = serverSupabaseServiceRole(event); + const {data, error} = await client.from('playlists').select(); + + if (error) { + setResponseStatus(event, 500); + return {error: error.message}; + } + + return data; +}) \ No newline at end of file diff --git a/server/api/v1/playlist/index.post.ts b/server/api/v1/playlist/index.post.ts new file mode 100644 index 0000000..8a756a2 --- /dev/null +++ b/server/api/v1/playlist/index.post.ts @@ -0,0 +1,55 @@ +import {z} from 'zod' +import {spotifyIDRegex} from "~/server/utils/data-validation"; +import {serverSupabaseServiceRole} from "#supabase/server"; +import {UNIQUE_VIOLATION} from "~/server/utils/postgres-errors"; +import {getPlaylistCover, getSpotifyToken} from "~/server/utils/spotify"; + +const schema = z.object({ + id: z.string().regex(spotifyIDRegex), + name: z.string(), + spotifyId: z.string().regex(spotifyIDRegex), + categories: z.array(z.string()), + enabled: z.boolean().optional() +}) + + +/** + * Unauthenticated endpoint to create a playlist - management only + * @throws {400} - Invalid body + * @throws {400} - Playlist with this ID already exists + * @throws {500} - Internal Server Error + * @returns {Object} - Created playlist + */ +export default defineEventHandler(async (event) => { + const result = await readValidatedBody(event, body => schema.safeParse(body)) + + if (!result.success) { + setResponseStatus(event, 400); + return {error: result.error.issues}; + } + + const token = await getSpotifyToken(); + const coverUrl = await getPlaylistCover(token, result.data.spotifyId); + + const playlistInsert = { + id: result.data.id, + name: result.data.name, + spotifyId: result.data.spotifyId, + cover: coverUrl + } + + const client = serverSupabaseServiceRole(event) + const {data, error} = await client.from('playlists').insert(playlistInsert as never).select().single(); //todo: fix type error! + + if (error) { + setResponseStatus(event, 400); + if (error.code === UNIQUE_VIOLATION) + return {error: 'Playlist with this ID already exists'}; + setResponseStatus(event, 500); + return {error: error.message}; + } + + + setResponseStatus(event, 201); + return data; +}) \ No newline at end of file diff --git a/server/api/v1/playlist/playlist.http b/server/api/v1/playlist/playlist.http new file mode 100644 index 0000000..e958f8c --- /dev/null +++ b/server/api/v1/playlist/playlist.http @@ -0,0 +1,36 @@ +@baseUrl = http://localhost:3000/api/v1 +@authCookie = + +### Get all Playlists +GET {{baseUrl}}/playlist +Cookie: {{authCookie}} + + +### Get specific Playlist +@playlistId = 37i9dQZF1EIYE32WUF6sxN + +GET {{baseUrl}}/playlist/{{playlistId}} +Cookie: {{authCookie}} + + +### Add a Spotify Playlist to our system +POST {{baseUrl}}/playlist + +{ +"id": "37i9dQZF1EIYE32WUF6sxN", +"name": "Hardstyle Popular", +"spotifyId": "37i9dQZF1EIYE32WUF6sxN", +"categories": ["hardstyle", "remix"] +} + +### Add a Disabled Spotify Playlist to our system +POST {{baseUrl}}/playlist + +{ +"id": "4DC0uhUb7itYVuVVF3goSN", +"name": "Memes", +"spotifyId": "4DC0uhUb7itYVuVVF3goSN", +"categories": ["meme"], +"enabled": false +} + diff --git a/server/utils/postgres-errors.ts b/server/utils/postgres-errors.ts new file mode 100644 index 0000000..bbc1982 --- /dev/null +++ b/server/utils/postgres-errors.ts @@ -0,0 +1,4 @@ +export const UNIQUE_VIOLATION = '23505'; +export const FOREIGN_KEY_VIOLATION = '23503'; +export const NOT_NULL_VIOLATION = '23502'; + diff --git a/server/utils/spotify.ts b/server/utils/spotify.ts new file mode 100644 index 0000000..3639c9f --- /dev/null +++ b/server/utils/spotify.ts @@ -0,0 +1,46 @@ +let spotifyToken: string | null = null +let tokenExpiry: number = 0 + +export async function getSpotifyToken() { + // Return cached token if valid + if (spotifyToken && tokenExpiry > Date.now()) { + return spotifyToken + } + + const auth = Buffer + .from(`${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`) + .toString('base64') + + try { + const res = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: 'grant_type=client_credentials' + }) + + const data = await res.json() + spotifyToken = data.access_token + tokenExpiry = Date.now() + (data.expires_in - 60) * 1000 + + return spotifyToken + } catch (error) { + console.error('Spotify token error:', error) + throw createError({ + statusCode: 500, + message: 'Failed to get Spotify access token' + }) + } +} + +export async function getPlaylistCover(token: string | null, playlistId: string): Promise { + if (!token) return undefined; + + const res = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}?fields=images`, { + headers: {'Authorization': `Bearer ${token}`} + }) + const data = await res.json() + return data.images[0].url; +} \ No newline at end of file