diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts
index f75110c..e2c323a 100644
--- a/src/config/env.validation.ts
+++ b/src/config/env.validation.ts
@@ -11,7 +11,12 @@ const REQUIRED_ENV_KEYS = [
// Optional keys that, when present, must pass a format check.
const BOOLEAN_KEYS = ["AUTH_COOKIE_SECURE"] as const;
const NUMBER_KEYS = ["PORT", "MAIL_PORT"] as const;
-const URL_KEYS = ["CLIENT_URL", "API_URL", "GOOGLE_CALLBACK_URL", "CDN_URL"] as const;
+const URL_KEYS = [
+ "CLIENT_URL",
+ "API_URL",
+ "GOOGLE_CALLBACK_URL",
+ "CDN_URL",
+] as const;
function isValidUrl(value: string): boolean {
try {
diff --git a/src/playlists/dto/get-playlist-embed-code.dto.ts b/src/playlists/dto/get-playlist-embed-code.dto.ts
index 12f18c3..b25ab7b 100644
--- a/src/playlists/dto/get-playlist-embed-code.dto.ts
+++ b/src/playlists/dto/get-playlist-embed-code.dto.ts
@@ -63,6 +63,6 @@ export class GetPlaylistEmbedCodeResponseDto {
@ApiProperty({ example: 'pl_101' })
playlistId!: string;
- @ApiProperty({ example: '' })
+ @ApiProperty({ example: '' })
embedCode!: string;
}
diff --git a/src/playlists/playlists.controller.spec.ts b/src/playlists/playlists.controller.spec.ts
index bc27719..5c8fc99 100644
--- a/src/playlists/playlists.controller.spec.ts
+++ b/src/playlists/playlists.controller.spec.ts
@@ -89,7 +89,7 @@ function buildServiceMock() {
}),
getEmbedCode: jest.fn().mockResolvedValue({
playlistId,
- embedCode: ``,
+ embedCode: ``,
}),
addTrack: jest.fn().mockResolvedValue({
message: "Track added to playlist successfully",
diff --git a/src/playlists/playlists.controller.ts b/src/playlists/playlists.controller.ts
index c04f372..2ce91cd 100644
--- a/src/playlists/playlists.controller.ts
+++ b/src/playlists/playlists.controller.ts
@@ -466,7 +466,7 @@ export class PlaylistsController {
schema: {
example: {
playlistId: 'pl_101',
- embedCode: '',
+ embedCode: '',
},
},
})
diff --git a/src/playlists/playlists.service.spec.ts b/src/playlists/playlists.service.spec.ts
index db73e46..ad8bef7 100644
--- a/src/playlists/playlists.service.spec.ts
+++ b/src/playlists/playlists.service.spec.ts
@@ -72,8 +72,14 @@ describe("PlaylistsService", () => {
const storageMock = {
upload: jest.fn(),
};
+ const originalEnv = process.env;
beforeEach(async () => {
+ process.env = {
+ ...originalEnv,
+ FRONTEND_URL: "https://dev.iqa3.tech",
+ };
+
prisma = buildPrismaMock();
const module: TestingModule = await Test.createTestingModule({
@@ -87,7 +93,10 @@ describe("PlaylistsService", () => {
service = module.get(PlaylistsService);
});
- afterEach(() => jest.clearAllMocks());
+ afterEach(() => {
+ process.env = originalEnv;
+ jest.clearAllMocks();
+ });
describe("create", () => {
it("creates a PUBLIC playlist with initial tracks list", async () => {
@@ -1451,16 +1460,33 @@ describe("PlaylistsService", () => {
prisma.playlist.findFirst.mockResolvedValue({
id: "pl_101",
ownerId: "usr_1",
+ visibility: PlaylistVisibility.PUBLIC,
+ secretToken: null,
});
const result = await service.getEmbedCode("usr_1", "pl_101");
expect(result).toEqual({
playlistId: "pl_101",
- embedCode: '',
+ embedCode: '',
});
});
+ it("includes the secret token for secret playlists", async () => {
+ prisma.playlist.findFirst.mockResolvedValue({
+ id: "pl_101",
+ ownerId: "usr_1",
+ visibility: PlaylistVisibility.SECRET,
+ secretToken: "secret-token-123",
+ });
+
+ const result = await service.getEmbedCode("usr_1", "pl_101");
+
+ expect(result.embedCode).toBe(
+ '',
+ );
+ });
+
it("throws when requester is not owner", async () => {
prisma.playlist.findFirst.mockResolvedValue({
id: "pl_101",
diff --git a/src/playlists/playlists.service.ts b/src/playlists/playlists.service.ts
index 008c66d..cc03d28 100644
--- a/src/playlists/playlists.service.ts
+++ b/src/playlists/playlists.service.ts
@@ -5,6 +5,7 @@ import {
Injectable,
Logger,
NotFoundException,
+ InternalServerErrorException,
} from '@nestjs/common';
import { PlaylistType, PlaylistVisibility, Prisma } from '@prisma/client';
import { randomBytes, randomUUID } from 'crypto';
@@ -1730,8 +1731,7 @@ export class PlaylistsService {
);
}
- const embedBaseUrl = this.resolveEmbedBaseUrl();
- const embedUrl = new URL(`${embedBaseUrl.replace(/\/+$/, '')}/${playlist.id}`);
+ const embedUrl = this.buildPlaylistEmbedUrl(playlist.id);
if (playlist.visibility === PlaylistVisibility.SECRET && playlist.secretToken) {
embedUrl.searchParams.set('token', playlist.secretToken);
@@ -1758,13 +1758,35 @@ export class PlaylistsService {
};
}
- private resolveEmbedBaseUrl(): string {
- const explicitBaseUrl = process.env.PLAYLIST_EMBED_BASE_URL?.trim();
- if (explicitBaseUrl) {
- return explicitBaseUrl;
+ private buildPlaylistEmbedUrl(playlistId: string): URL {
+ const baseUrl = this.resolveFrontendBaseUrl();
+ return new URL(`/embed/playlists/${playlistId}`, baseUrl);
+ }
+
+ private resolveFrontendBaseUrl(): string {
+ const configuredBaseUrl =
+ process.env.FRONTEND_URL?.trim() ?? process.env.CLIENT_URL?.trim();
+
+ if (!configuredBaseUrl) {
+ const message =
+ 'Playlist embed base URL is not configured. Set FRONTEND_URL or CLIENT_URL to a valid http/https URL.';
+ this.logger.error(message);
+ throw new InternalServerErrorException(message);
}
- return 'https://example.com/embed/playlists';
+ try {
+ const url = new URL(configuredBaseUrl);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('URL must use http or https.');
+ }
+
+ return url.toString().replace(/\/+$/, '');
+ } catch {
+ const message =
+ `Playlist embed base URL is invalid: ${configuredBaseUrl}. Set FRONTEND_URL or CLIENT_URL to a valid http/https URL.`;
+ this.logger.error(message);
+ throw new InternalServerErrorException(message);
+ }
}
private validateTrackIdArray(