From 803e4633a27be10d80e752d62bd0dab09ed2924a Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Mon, 23 Feb 2026 20:41:55 +0600 Subject: [PATCH] feat: api documentation versioning (#5676) Co-authored-by: nivedin Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- .../migration.sql | 31 + .../migration.sql | 4 + .../hoppscotch-backend/prisma/schema.prisma | 31 +- packages/hoppscotch-backend/src/errors.ts | 7 + .../src/published-docs/input-type.args.ts | 21 + .../published-docs.controller.ts | 49 +- .../src/published-docs/published-docs.dto.ts | 19 - .../published-docs/published-docs.model.ts | 124 ++ .../published-docs/published-docs.resolver.ts | 20 +- .../published-docs.service.spec.ts | 1013 ++++++++++++++++- .../published-docs/published-docs.service.ts | 307 ++++- .../team-collection.service.ts | 31 +- .../user-collection.service.ts | 41 +- packages/hoppscotch-common/locales/en.json | 28 +- .../hoppscotch-common/src/components.d.ts | 11 + .../documentation/CollectionStructure.vue | 9 +- .../documentation/EnvironmentPicker.vue | 191 ++++ .../collections/documentation/Preview.vue | 1 + .../documentation/PublishDocForm.vue | 169 +++ .../documentation/PublishDocModal.vue | 165 ++- .../PublishDocSnapshotPreview.vue | 406 +++++++ .../documentation/RequestPreview.vue | 38 +- .../collections/documentation/index.vue | 391 +++++-- .../documentation/sections/CurlView.vue | 16 + .../src/components/documentation/Content.vue | 46 +- .../src/components/documentation/Header.vue | 212 +++- .../gql/mutations/CreatePublishedDoc.graphql | 1 + .../gql/mutations/UpdatePublishedDoc.graphql | 1 + .../backend/gql/queries/PublishedDoc.graphql | 2 + .../gql/queries/TeamPublishedDocsList.graphql | 2 + .../gql/queries/UserPublishedDocsList.graphql | 2 + .../helpers/backend/queries/PublishedDocs.ts | 40 +- .../src/helpers/utils/EffectiveURL.ts | 7 +- .../src/pages/view/_id/_version.vue | 108 +- .../__tests__/documentation.service.spec.ts | 199 +++- .../src/services/documentation.service.ts | 101 +- .../hoppscotch-data/src/environment/index.ts | 87 +- 37 files changed, 3446 insertions(+), 485 deletions(-) create mode 100644 packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql create mode 100644 packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql delete mode 100644 packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts create mode 100644 packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue create mode 100644 packages/hoppscotch-common/src/components/collections/documentation/PublishDocForm.vue create mode 100644 packages/hoppscotch-common/src/components/collections/documentation/PublishDocSnapshotPreview.vue diff --git a/packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql new file mode 100644 index 00000000..03ae84ca --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251207122817_add_slug_to_published_docs/migration.sql @@ -0,0 +1,31 @@ +-- Step 1: Add slug column as nullable first +ALTER TABLE "PublishedDocs" ADD COLUMN "slug" TEXT; + +-- Step 2: For backward compatibility, set slug = id for existing records +UPDATE "PublishedDocs" SET "slug" = "id" WHERE "slug" IS NULL; + +-- Step 3: Handle duplicates - for multiple published docs with same collection and version +-- Keep the latest one (most recent), delete all older ones +-- delete old duplicates are safe, as multiple published docs with same collection and version is not expected behavior till v2025.11.x +WITH ranked_docs AS ( + SELECT + id, + "collectionID", + version, + "createdOn", + ROW_NUMBER() OVER (PARTITION BY "collectionID", version ORDER BY "createdOn" DESC) as rn + FROM "PublishedDocs" +) +DELETE FROM "PublishedDocs" +WHERE id IN ( + SELECT id FROM ranked_docs WHERE rn > 1 +); + +-- Step 4: Now make slug NOT NULL +ALTER TABLE "PublishedDocs" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE INDEX "PublishedDocs_collectionID_idx" ON "PublishedDocs"("collectionID"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublishedDocs_slug_version_key" ON "PublishedDocs"("slug", "version"); diff --git a/packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql new file mode 100644 index 00000000..7c9d0aa0 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20260209063744_published_doc_environment/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "PublishedDocs" ADD COLUMN "environmentID" TEXT, +ADD COLUMN "environmentName" TEXT, +ADD COLUMN "environmentVariables" JSONB; diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 3fb074bb..26a3b263 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -297,18 +297,25 @@ model MockServerActivity { } model PublishedDocs { - id String @id @default(cuid()) - title String - collectionID String - creatorUid String - version String - autoSync Boolean - documentTree Json? // Optional if autoSync is true - workspaceType WorkspaceType - workspaceID String - metadata Json? - createdOn DateTime @default(now()) @db.Timestamptz(3) - updatedOn DateTime @updatedAt @db.Timestamptz(3) + id String @id @default(cuid()) + slug String + title String + collectionID String + creatorUid String + version String + autoSync Boolean + documentTree Json? // Optional if autoSync is true + workspaceType WorkspaceType + workspaceID String + environmentID String? + environmentName String? + environmentVariables Json? + metadata Json? + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + + @@unique([slug, version]) + @@index([collectionID]) } enum WorkspaceType { diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 5affb9f7..723f013f 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -975,6 +975,13 @@ export const PUBLISHED_DOCS_UPDATE_FAILED = 'published_docs/update_failed'; */ export const PUBLISHED_DOCS_DELETION_FAILED = 'published_docs/deletion_failed'; +/** + * Published Docs invalid environment + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_INVALID_ENVIRONMENT = + 'published_docs/invalid_environment'; + /** * Published Docs not found * (PublishedDocsService) diff --git a/packages/hoppscotch-backend/src/published-docs/input-type.args.ts b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts index 29f22cf8..af3b9681 100644 --- a/packages/hoppscotch-backend/src/published-docs/input-type.args.ts +++ b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts @@ -51,6 +51,15 @@ export class CreatePublishedDocsArgs { description: 'Metadata associated with the published document', }) metadata: string; + + @Field({ + name: 'environmentID', + description: + 'ID of the environment to associate with the published document', + nullable: true, + }) + @IsOptional() + environmentID?: string; } @InputType() @@ -60,6 +69,7 @@ export class UpdatePublishedDocsArgs { description: 'Title of the published document', nullable: true, }) + @IsOptional() title?: string; @Field({ @@ -80,6 +90,7 @@ export class UpdatePublishedDocsArgs { 'Whether the published document should auto-sync with the source', nullable: true, }) + @IsOptional() autoSync?: boolean; @Field({ @@ -87,5 +98,15 @@ export class UpdatePublishedDocsArgs { description: 'Metadata associated with the published document', nullable: true, }) + @IsOptional() metadata?: string; + + @Field({ + name: 'environmentID', + description: + 'ID of the environment to associate with the published document. Pass null to remove the environment.', + nullable: true, + }) + @IsOptional() + environmentID?: string; } diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts index c61e80c0..25b07789 100644 --- a/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts @@ -2,14 +2,12 @@ import { Controller, Get, Param, - Query, HttpCode, HttpStatus, UseGuards, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { PublishedDocsService } from './published-docs.service'; -import { GetPublishedDocsQueryDto } from './published-docs.dto'; import * as E from 'fp-ts/Either'; import { throwHTTPErr } from 'src/utils'; import { PublishedDocs } from './published-docs.model'; @@ -21,12 +19,12 @@ import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.gua export class PublishedDocsController { constructor(private readonly publishedDocsService: PublishedDocsService) {} - @Get(':docId') + @Get(':slug') @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Get published documentation', + summary: 'Get latest published documentation by slug', description: - 'Returns published collection documentation in API-doc JSON format for unauthenticated users', + 'Returns the latest version of published collection documentation by slug for unauthenticated users.', }) @ApiResponse({ status: 200, @@ -37,13 +35,42 @@ export class PublishedDocsController { status: 404, description: 'Published documentation not found', }) - async getPublishedDocs( - @Param('docId') docId: string, - @Query() query: GetPublishedDocsQueryDto, + async getPublishedDocsBySlugLatest(@Param('slug') slug: string) { + const result = await this.publishedDocsService.getPublishedDocBySlugPublic( + slug, + null, + ); + + if (E.isLeft(result)) { + throwHTTPErr({ message: result.left, statusCode: HttpStatus.NOT_FOUND }); + } + + return result.right; + } + + @Get(':slug/:version') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get published documentation by slug and version', + description: + 'Returns published collection documentation by slug and version for unauthenticated users.', + }) + @ApiResponse({ + status: 200, + description: 'Successfully retrieved published documentation', + type: () => PublishedDocs, + }) + @ApiResponse({ + status: 404, + description: 'Published documentation not found', + }) + async getPublishedDocsBySlug( + @Param('slug') slug: string, + @Param('version') version: string, ) { - const result = await this.publishedDocsService.getPublishedDocByIDPublic( - docId, - query, + const result = await this.publishedDocsService.getPublishedDocBySlugPublic( + slug, + version, ); if (E.isLeft(result)) { diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts deleted file mode 100644 index baca5632..00000000 --- a/packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsEnum, IsOptional } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; - -export enum TreeLevel { - FULL = 'full', - FIRST_LEVEL = 'first_level', -} - -export class GetPublishedDocsQueryDto { - @ApiPropertyOptional({ - description: 'Specifies whether to return full tree or only first level', - enum: TreeLevel, - default: TreeLevel.FULL, - required: false, - }) - @IsOptional() - @IsEnum(TreeLevel) - tree?: TreeLevel = TreeLevel.FULL; -} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts index 97c1c02c..08ee1b90 100644 --- a/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts @@ -1,5 +1,69 @@ import { ObjectType, Field, ID } from '@nestjs/graphql'; import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; + +@ObjectType() +export class PublishedDocsVersion { + @Field(() => ID, { + description: 'ID of the published document version', + }) + @ApiProperty({ + description: 'ID of the published document version', + example: 'doc_12345', + }) + @Expose() + id: string; + + @Field(() => String, { + description: 'Slug of the published document', + }) + @ApiProperty({ + description: 'Slug of the published document', + example: 'abc-123-uuid', + }) + @Expose() + slug: string; + + @Field(() => String, { + description: 'Version string', + }) + @ApiProperty({ + description: 'Version string', + example: '1.0.0', + }) + @Expose() + version: string; + + @Field(() => String, { + description: 'Title of the API documentation', + }) + @ApiProperty({ + description: 'Title of the API documentation', + example: 'API Documentation v1.0', + }) + @Expose() + title: string; + + @Field(() => Boolean, { + description: 'Indicates if the documentation is set to auto-sync', + }) + @ApiProperty({ + description: 'Indicates if the documentation is set to auto-sync', + example: true, + }) + @Expose() + autoSync: boolean; + + @Field(() => String, { + description: 'URL where the published API documentation can be accessed', + }) + @ApiProperty({ + description: 'URL where the published API documentation can be accessed', + example: 'https://docs.example.com/api/v1.0', + }) + @Expose() + url: string; +} @ObjectType() export class PublishedDocs { @@ -10,13 +74,27 @@ export class PublishedDocs { description: 'ID of the published API documentation', example: 'doc_12345', }) + @Expose() id: string; + @Field(() => ID, { + description: + 'Slug of the published API documentation (unique with version)', + }) + @ApiProperty({ + description: + 'Slug of the published API documentation (unique with version)', + example: 'my-api-docs', + }) + @Expose() + slug: string; + @Field({ description: 'Title of the published API documentation' }) @ApiProperty({ description: 'Title of the published API documentation', example: 'My API Documentation', }) + @Expose() title: string; @Field({ @@ -26,6 +104,7 @@ export class PublishedDocs { description: 'URL where the published API documentation can be accessed', example: 'https://docs.example.com/api', }) + @Expose() url: string; @Field({ description: 'Version of the published API documentation' }) @@ -33,6 +112,7 @@ export class PublishedDocs { description: 'Version of the published API documentation', example: '1.0.0', }) + @Expose() version: string; @Field({ description: 'Indicates if the documentation is set to auto-sync' }) @@ -40,6 +120,7 @@ export class PublishedDocs { description: 'Indicates if the documentation is set to auto-sync', example: true, }) + @Expose() autoSync: boolean; @Field({ @@ -50,6 +131,7 @@ export class PublishedDocs { example: '{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}', }) + @Expose() documentTree: string; @Field({ @@ -79,13 +161,42 @@ export class PublishedDocs { description: 'Metadata of the documentation', example: '{"author": "John Doe", "tags": ["api", "rest"]}', }) + @Expose() metadata: string; + @Field({ + description: 'Name of the environment associated with the documentation', + nullable: true, + }) + @ApiProperty({ + description: 'Name of the environment associated with the documentation', + example: 'Production', + nullable: true, + }) + @Expose() + environmentName?: string; + + @Field({ + description: + 'Stringified JSON of the environment variables associated with the documentation', + nullable: true, + }) + @ApiProperty({ + description: + 'Stringified JSON of the environment variables associated with the documentation', + example: + '[{"key":"base_url","secret":false,"currentValue":"","initialValue":"http://hoppscotch.com"}]', + nullable: true, + }) + @Expose() + environmentVariables?: string; + @Field({ description: 'Timestamp when the documentation was created' }) @ApiProperty({ description: 'Timestamp when the documentation was created', example: '2024-01-01T00:00:00.000Z', }) + @Expose() createdOn: Date; @Field({ description: 'Timestamp when the documentation was last updated' }) @@ -93,7 +204,20 @@ export class PublishedDocs { description: 'Timestamp when the documentation was last updated', example: '2024-01-15T12:30:00.000Z', }) + @Expose() updatedOn: Date; + + @Field(() => [PublishedDocsVersion], { + description: 'All available versions of this published documentation', + nullable: true, + }) + @ApiProperty({ + description: 'All available versions of this published documentation', + type: [PublishedDocsVersion], + }) + @Expose() + @Type(() => PublishedDocsVersion) + versions?: PublishedDocsVersion[]; } @ObjectType() diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts index 11e72ec2..ecb26323 100644 --- a/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts @@ -9,7 +9,11 @@ import { Query, } from '@nestjs/graphql'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; -import { PublishedDocs, PublishedDocsCollection } from './published-docs.model'; +import { + PublishedDocs, + PublishedDocsCollection, + PublishedDocsVersion, +} from './published-docs.model'; import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { @@ -60,6 +64,20 @@ export class PublishedDocsResolver { return collection.right; } + @ResolveField(() => [PublishedDocsVersion], { + description: 'Returns all versions of the published document (same slug)', + }) + async versions( + @Parent() publishedDocs: PublishedDocs, + ): Promise { + const versions = await this.publishedDocsService.getPublishedDocsVersions( + publishedDocs.slug, + ); + + if (E.isLeft(versions)) throwErr(versions.left); + return versions.right; + } + // Queries @Query(() => PublishedDocs, { diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts index e532eb61..5d7769f4 100644 --- a/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts @@ -4,6 +4,7 @@ import { PUBLISHED_DOCS_CREATION_FAILED, PUBLISHED_DOCS_DELETION_FAILED, PUBLISHED_DOCS_INVALID_COLLECTION, + PUBLISHED_DOCS_INVALID_ENVIRONMENT, PUBLISHED_DOCS_NOT_FOUND, PUBLISHED_DOCS_UPDATE_FAILED, TEAM_INVALID_ID, @@ -21,7 +22,6 @@ import { UpdatePublishedDocsArgs, } from './input-type.args'; import { TeamAccessRole } from 'src/team/team.model'; -import { TreeLevel } from './published-docs.dto'; import { ConfigService } from '@nestjs/config'; const mockPrisma = mockDeep(); @@ -53,6 +53,7 @@ const user: User = { const userPublishedDoc: DBPublishedDocs = { id: 'pub_doc_1', + slug: 'slug-collection-1', title: 'User API Documentation', version: '1.0.0', autoSync: true, @@ -62,12 +63,16 @@ const userPublishedDoc: DBPublishedDocs = { collectionID: 'collection_1', creatorUid: user.uid, metadata: {}, + environmentID: null, + environmentName: null, + environmentVariables: null, createdOn: currentTime, updatedOn: currentTime, }; const userPublishedDocCasted: PublishedDocs = { id: userPublishedDoc.id, + slug: userPublishedDoc.slug, title: userPublishedDoc.title, version: userPublishedDoc.version, autoSync: userPublishedDoc.autoSync, @@ -75,13 +80,16 @@ const userPublishedDocCasted: PublishedDocs = { workspaceType: userPublishedDoc.workspaceType, workspaceID: userPublishedDoc.workspaceID, metadata: JSON.stringify(userPublishedDoc.metadata), + environmentName: null, + environmentVariables: null, createdOn: userPublishedDoc.createdOn, updatedOn: userPublishedDoc.updatedOn, - url: `${mockConfigService.get('VITE_BASE_URL')}/view/${userPublishedDoc.id}/${userPublishedDoc.version}`, + url: `${mockConfigService.get('VITE_BASE_URL')}/view/${userPublishedDoc.slug}/${userPublishedDoc.version}`, }; const teamPublishedDoc: DBPublishedDocs = { id: 'pub_doc_2', + slug: 'slug-team-collection-1', title: 'Team API Documentation', version: '1.0.0', autoSync: true, @@ -91,12 +99,16 @@ const teamPublishedDoc: DBPublishedDocs = { collectionID: 'team_collection_1', creatorUid: user.uid, metadata: {}, + environmentID: null, + environmentName: null, + environmentVariables: null, createdOn: currentTime, updatedOn: currentTime, }; const teamPublishedDocCasted: PublishedDocs = { id: teamPublishedDoc.id, + slug: teamPublishedDoc.slug, title: teamPublishedDoc.title, version: teamPublishedDoc.version, autoSync: teamPublishedDoc.autoSync, @@ -104,9 +116,11 @@ const teamPublishedDocCasted: PublishedDocs = { workspaceType: teamPublishedDoc.workspaceType, workspaceID: teamPublishedDoc.workspaceID, metadata: JSON.stringify(teamPublishedDoc.metadata), + environmentName: null, + environmentVariables: null, createdOn: teamPublishedDoc.createdOn, updatedOn: teamPublishedDoc.updatedOn, - url: `${mockConfigService.get('VITE_BASE_URL')}/view/${teamPublishedDoc.id}/${teamPublishedDoc.version}`, + url: `${mockConfigService.get('VITE_BASE_URL')}/view/${teamPublishedDoc.slug}/${teamPublishedDoc.version}`, }; beforeEach(() => { @@ -597,6 +611,10 @@ describe('updatePublishedDoc', () => { test('should successfully update a published document with valid inputs', async () => { mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + // autoSync switching from true → false requires exporting collection snapshot + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); mockPrisma.publishedDocs.update.mockResolvedValueOnce({ ...userPublishedDoc, title: updateArgs.title, @@ -658,6 +676,10 @@ describe('updatePublishedDoc', () => { test('should successfully update team published document when user has OWNER role', async () => { mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + // autoSync switching from true → false requires exporting collection snapshot + mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); mockPrisma.publishedDocs.update.mockResolvedValueOnce({ ...teamPublishedDoc, title: updateArgs.title, @@ -675,6 +697,10 @@ describe('updatePublishedDoc', () => { test('should successfully update team published document when user has EDITOR role', async () => { mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + // autoSync switching from true → false requires exporting collection snapshot + mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); mockPrisma.publishedDocs.update.mockResolvedValueOnce({ ...teamPublishedDoc, title: updateArgs.title, @@ -979,8 +1005,111 @@ describe('checkPublishedDocsAccess', () => { }); }); -describe('getPublishedDocByIDPublic', () => { - test('should return collection data when autoSync is enabled for user workspace', async () => { +describe('getPublishedDocsVersions', () => { + test('should return all versions for a given slug ordered by autoSync and createdOn', async () => { + const mockDbRecords = [ + { + id: 'pub_doc_1', + slug: 'slug-collection-1', + version: '1.0.0', + title: 'API Docs v1', + autoSync: true, + collectionID: 'coll_1', + creatorUid: 'user_1', + workspaceType: 'USER' as any, + workspaceID: 'workspace_1', + documentTree: { folders: [] }, + metadata: { description: 'v1' }, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'pub_doc_2', + slug: 'slug-collection-1', + version: '2.0.0', + title: 'API Docs v2', + autoSync: true, + collectionID: 'coll_1', + creatorUid: 'user_1', + workspaceType: 'USER' as any, + workspaceID: 'workspace_1', + documentTree: { folders: [] }, + metadata: { description: 'v2' }, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'pub_doc_3', + slug: 'slug-collection-1', + version: '3.0.0', + title: 'API Docs v3', + autoSync: false, + collectionID: 'coll_1', + creatorUid: 'user_1', + workspaceType: 'USER' as any, + workspaceID: 'workspace_1', + documentTree: { folders: [] }, + metadata: { description: 'v3' }, + environmentID: null, + environmentName: null, + environmentVariables: null, + createdOn: new Date(), + updatedOn: new Date(), + }, + ]; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce( + mockDbRecords as any, + ); + + const result = + await publishedDocsService.getPublishedDocsVersions('slug-collection-1'); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + // cast() adds versions array, stringifies documentTree/metadata, and adds url + expect(result.right).toHaveLength(3); + expect(result.right[0]).toMatchObject({ + id: 'pub_doc_1', + slug: 'slug-collection-1', + version: '1.0.0', + title: 'API Docs v1', + autoSync: true, + }); + expect(result.right[0].url).toContain('/view/slug-collection-1/1.0.0'); + expect(result.right[0].versions).toEqual([]); + } + }); + + test('should return PUBLISHED_DOCS_NOT_FOUND when no versions found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + + const result = + await publishedDocsService.getPublishedDocsVersions('non-existent-slug'); + + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should query with correct orderBy clause for autoSync priority', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + + await publishedDocsService.getPublishedDocsVersions('test-slug'); + + expect(mockPrisma.publishedDocs.findMany).toHaveBeenCalledWith({ + where: { slug: 'test-slug' }, + orderBy: [{ autoSync: 'desc' }, { createdOn: 'desc' }], + }); + }); +}); + +describe('getPublishedDocBySlugPublic', () => { + test('should return published document by slug and version with autoSync enabled', async () => { const collectionData = { id: 'collection_1', name: 'Test Collection', @@ -992,95 +1121,855 @@ describe('getPublishedDocByIDPublic', () => { ...userPublishedDoc, autoSync: true, }); + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: userPublishedDoc.id, + slug: userPublishedDoc.slug, + version: userPublishedDoc.version, + title: userPublishedDoc.title, + autoSync: userPublishedDoc.autoSync, + }, + ] as any); mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( E.right(collectionData as any), ); - const result = await publishedDocsService.getPublishedDocByIDPublic( - userPublishedDoc.id, - { tree: TreeLevel.FULL }, + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', ); - expect(result).toMatchObject( - E.right({ - ...userPublishedDocCasted, - documentTree: JSON.stringify(collectionData), + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.slug).toBe('slug-collection-1'); + expect(result.right.version).toBe('1.0.0'); + expect(result.right.documentTree).toBe(JSON.stringify(collectionData)); + } + }); + + test('should return published document with stored documentTree when autoSync is false', async () => { + const storedDocTree = { folders: [], requests: [] }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: false, + documentTree: storedDocTree, + }); + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: userPublishedDoc.id, + slug: userPublishedDoc.slug, + version: userPublishedDoc.version, + title: userPublishedDoc.title, + autoSync: false, + }, + ] as any); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.documentTree).toBe(JSON.stringify(storedDocTree)); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when slug and version combination not found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'non-existent-slug', + '1.0.0', + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should use unique constraint slug_version for lookup', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: 'v1', + slug: 'test-slug', + version: '2.0.0', + title: 'V1', + autoSync: true, + }, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + await publishedDocsService.getPublishedDocBySlugPublic( + 'test-slug', + '2.0.0', + ); + + expect(mockPrisma.publishedDocs.findUnique).toHaveBeenCalledWith({ + where: { + slug_version: { + slug: 'test-slug', + version: '2.0.0', + }, + }, + }); + }); + + test('should fetch all versions for the slug', async () => { + const allVersions = [ + { + id: 'v1', + slug: 'test-slug', + version: '1.0.0', + title: 'V1', + autoSync: true, + }, + { + id: 'v2', + slug: 'test-slug', + version: '2.0.0', + title: 'V2', + autoSync: true, + }, + ]; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: false, + }); + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce(allVersions as any); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'test-slug', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should use first version as default when no version specified and versions exist', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + { + id: 'v1', + slug: 'test-slug', + version: 'CURRENT', + title: 'V1', + autoSync: true, + }, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + version: 'CURRENT', + autoSync: false, + }); + + await publishedDocsService.getPublishedDocBySlugPublic('test-slug', null); + + expect(mockPrisma.publishedDocs.findUnique).toHaveBeenCalledWith({ + where: { + slug_version: { + slug: 'test-slug', + version: 'CURRENT', + }, + }, + }); + }); +}); + +describe('createPublishedDoc - slug generation and race conditions', () => { + const createArgs: CreatePublishedDocsArgs = { + title: 'New API Documentation', + version: '1.0.0', + autoSync: true, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + metadata: '{}', + }; + + test('should generate new slug for first version of a collection', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + // No existing docs for this collection + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + slug: expect.any(String), + }); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.findFirst).toHaveBeenCalledWith({ + where: { + collectionID: 'collection_1', + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + }, + orderBy: { + createdOn: 'asc', + }, + }); + }); + + test('should reuse existing slug for subsequent versions of same collection', async () => { + const existingSlug = 'existing-slug-abc'; + const existingDoc = { + ...userPublishedDoc, + slug: existingSlug, + version: '1.0.0', + }; + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(existingDoc); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + slug: existingSlug, + version: '2.0.0', + }); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, version: '2.0.0' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.slug).toBe(existingSlug); + } + }); + + test('should retry on race condition (P2002 error) up to 3 times', async () => { + const uniqueConstraintError = { + code: 'P2002', + meta: { target: ['slug', 'version'] }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValue(null); + + // First two attempts fail with P2002, third succeeds + mockPrisma.publishedDocs.create + .mockRejectedValueOnce(uniqueConstraintError) + .mockRejectedValueOnce(uniqueConstraintError) + .mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledTimes(3); + }); + + test('should fail after max retries (3 attempts)', async () => { + const uniqueConstraintError = { + code: 'P2002', + meta: { target: ['slug', 'version'] }, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValue({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValue(null); + + // All attempts fail with P2002 + mockPrisma.publishedDocs.create.mockRejectedValue(uniqueConstraintError); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledTimes(3); + }); + + test('should not retry on non-P2002 errors', async () => { + const otherError = new Error('Database connection failed'); + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockRejectedValueOnce(otherError); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledTimes(1); + }); + + test('should store null documentTree when autoSync is true', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc); + + await publishedDocsService.createPublishedDoc( + { ...createArgs, autoSync: true }, + user, + ); + + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + documentTree: null, + }), }), ); }); - test('should return collection data when autoSync is enabled for team workspace', async () => { - const collectionData = { + test('should fetch and store documentTree when autoSync is false', async () => { + const collectionData = { folders: [], requests: [] }; + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + documentTree: collectionData, + }); + + await publishedDocsService.createPublishedDoc( + { ...createArgs, autoSync: false }, + user, + ); + + expect( + mockUserCollectionService.exportUserCollectionToJSONObject, + ).toHaveBeenCalledWith(user.uid, 'collection_1'); + }); +}); + +describe('createPublishedDoc - environment support', () => { + const createArgs: CreatePublishedDocsArgs = { + title: 'New API Documentation', + version: '1.0.0', + autoSync: true, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + metadata: '{}', + }; + + test('should create published doc with environment for user workspace', async () => { + const envData = { + id: 'env_1', + userUid: user.uid, + name: 'Production', + variables: [{ key: 'BASE_URL', value: 'https://api.example.com' }], + isGlobal: false, + }; + + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...userPublishedDoc, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, environmentID: 'env_1' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: envData.variables, + }), + }), + ); + }); + + test('should create published doc with environment for team workspace', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + environmentID: 'team_env_1', + }; + const envData = { + id: 'team_env_1', + teamID: 'team_1', + name: 'Staging', + variables: [{ key: 'API_KEY', value: 'abc123' }], + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ id: 'team_collection_1', - name: 'Team Test Collection', + teamID: 'team_1', + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce({ + ...teamPublishedDoc, + environmentID: 'team_env_1', + environmentName: 'Staging', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: 'team_env_1', + environmentName: 'Staging', + environmentVariables: envData.variables, + }), + }), + ); + }); + + test('should return error when user environment ID is invalid', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, environmentID: 'invalid_env' }, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_ENVIRONMENT); + }); + + test('should return error when team environment ID is invalid', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + environmentID: 'invalid_env', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + id: 'team_collection_1', + teamID: 'team_1', + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_ENVIRONMENT); + }); + + test('should create published doc without environment when environmentID is not provided', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.findFirst.mockResolvedValueOnce(null); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: null, + environmentName: null, + environmentVariables: null, + }), + }), + ); + }); +}); + +describe('updatePublishedDoc - environment support', () => { + test('should update published doc with new environment', async () => { + const envData = { + id: 'env_2', + userUid: user.uid, + name: 'Staging', + variables: [{ key: 'API_URL', value: 'https://staging.example.com' }], + isGlobal: false, + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...userPublishedDoc, + environmentID: 'env_2', + environmentName: 'Staging', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { environmentID: 'env_2' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Staging'); + expect(result.right.environmentVariables).toBe( + JSON.stringify(envData.variables), + ); + } + }); + + test('should remove environment when environmentID is set to null', async () => { + const docWithEnv = { + ...userPublishedDoc, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...docWithEnv, + environmentID: null, + environmentName: null, + environmentVariables: null, + }); + + const result = await publishedDocsService.updatePublishedDoc( + docWithEnv.id, + { environmentID: null }, + user, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.publishedDocs.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: null, + environmentName: null, + environmentVariables: null, + }), + }), + ); + }); + + test('should return error when updating with invalid environment ID', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { environmentID: 'invalid_env' }, + user, + ); + + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_ENVIRONMENT); + }); + + test('should not change environment when environmentID is not provided in update args', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockResolvedValueOnce(userPublishedDoc); + + await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { title: 'Updated Title' }, + user, + ); + + expect(mockPrisma.publishedDocs.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + environmentID: undefined, + environmentName: undefined, + environmentVariables: undefined, + }), + }), + ); + }); + + test('should update environment for team published doc', async () => { + const envData = { + id: 'team_env_1', + teamID: 'team_1', + name: 'Team Staging', + variables: [{ key: 'TOKEN', value: 'xyz789' }], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(envData as any); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...teamPublishedDoc, + environmentID: 'team_env_1', + environmentName: 'Team Staging', + environmentVariables: envData.variables, + }); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + { environmentID: 'team_env_1' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Team Staging'); + } + }); +}); + +describe('getPublishedDocBySlugPublic - environment support', () => { + test('should re-fetch environment when autoSync is true and environmentID is set', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + const envData = { + id: 'env_1', + userUid: user.uid, + name: 'Updated Env Name', + variables: [{ key: 'BASE_URL', value: 'https://updated.example.com' }], + isGlobal: false, + }; + const docWithEnv = { + ...userPublishedDoc, + autoSync: true, + environmentID: 'env_1', + environmentName: 'Old Env Name', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://old.example.com' }, + ], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + docWithEnv, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(envData as any); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Updated Env Name'); + expect(result.right.environmentVariables).toBe( + JSON.stringify(envData.variables), + ); + } + }); + + test('should not re-fetch environment when autoSync is false', async () => { + const docWithEnv = { + ...userPublishedDoc, + autoSync: false, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + docWithEnv, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Production'); + expect(result.right.environmentVariables).toBe( + JSON.stringify(docWithEnv.environmentVariables), + ); + } + // Should not attempt to fetch environment + expect(mockPrisma.userEnvironment.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.teamEnvironment.findFirst).not.toHaveBeenCalled(); + }); + + test('should not re-fetch environment when autoSync is true but no environmentID', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', folders: [], requests: [], }; - mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ - ...teamPublishedDoc, - autoSync: true, - }); - mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + userPublishedDoc, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( E.right(collectionData as any), ); - const result = await publishedDocsService.getPublishedDocByIDPublic( - teamPublishedDoc.id, - { tree: TreeLevel.FULL }, + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', ); - expect(result).toMatchObject( - E.right({ - ...teamPublishedDocCasted, - documentTree: JSON.stringify(collectionData), - }), - ); + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.userEnvironment.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.teamEnvironment.findFirst).not.toHaveBeenCalled(); }); - test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { - mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); - - const result = await publishedDocsService.getPublishedDocByIDPublic( - 'invalid_id', - { tree: TreeLevel.FULL }, - ); - expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); - }); - - test('should call exportUserCollectionToJSONObject with correct parameters', async () => { - mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + test('should return error when re-fetch of environment fails', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + const docWithEnv = { ...userPublishedDoc, autoSync: true, - }); + environmentID: 'env_deleted', + environmentName: 'Deleted Env', + environmentVariables: [{ key: 'OLD', value: 'data' }], + }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + docWithEnv, + ] as any); + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( - E.right({} as any), + E.right(collectionData as any), + ); + // Environment not found — fetchEnvironment returns Left + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', ); - await publishedDocsService.getPublishedDocByIDPublic(userPublishedDoc.id, { - tree: TreeLevel.FULL, - } as any); - - expect( - mockUserCollectionService.exportUserCollectionToJSONObject, - ).toHaveBeenCalledWith(user.uid, 'collection_1', true); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_ENVIRONMENT); }); - test('should call exportCollectionToJSONObject with correct parameters', async () => { + test('should return null environment fields when no environment is associated', async () => { + const storedDocTree = { folders: [], requests: [] }; + + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([ + userPublishedDoc, + ] as any); mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ - ...teamPublishedDoc, - autoSync: true, + ...userPublishedDoc, + autoSync: false, + documentTree: storedDocTree, }); - mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( - E.right({} as any), + + const result = await publishedDocsService.getPublishedDocBySlugPublic( + 'slug-collection-1', + '1.0.0', ); - await publishedDocsService.getPublishedDocByIDPublic(teamPublishedDoc.id, { - tree: TreeLevel.FULL, - }); - - expect( - mockTeamCollectionService.exportCollectionToJSONObject, - ).toHaveBeenCalledWith('team_1', 'team_collection_1', true); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBeNull(); + expect(result.right.environmentVariables).toBeNull(); + } + }); +}); + +describe('cast - environment stringification', () => { + test('should stringify environmentVariables in cast output', () => { + const docWithEnv: DBPublishedDocs = { + ...userPublishedDoc, + environmentID: 'env_1', + environmentName: 'Production', + environmentVariables: [ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ], + }; + + // Access private cast via getPublishedDocByID which calls cast internally + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(docWithEnv); + + return publishedDocsService + .getPublishedDocByID(docWithEnv.id, user) + .then((result) => { + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBe('Production'); + expect(typeof result.right.environmentVariables).toBe('string'); + expect(result.right.environmentVariables).toBe( + JSON.stringify([ + { key: 'BASE_URL', value: 'https://api.example.com' }, + ]), + ); + } + }); + }); + + test('should return null environmentVariables when not set', () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + + return publishedDocsService + .getPublishedDocByID(userPublishedDoc.id, user) + .then((result) => { + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.environmentName).toBeNull(); + expect(result.right.environmentVariables).toBeNull(); + } + }); }); }); diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts index 43e5001f..4b090c4d 100644 --- a/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; import { CreatePublishedDocsArgs, UpdatePublishedDocsArgs, @@ -12,6 +13,7 @@ import { PUBLISHED_DOCS_CREATION_FAILED, PUBLISHED_DOCS_DELETION_FAILED, PUBLISHED_DOCS_INVALID_COLLECTION, + PUBLISHED_DOCS_INVALID_ENVIRONMENT, PUBLISHED_DOCS_NOT_FOUND, PUBLISHED_DOCS_UPDATE_FAILED, TEAM_INVALID_COLL_ID, @@ -19,13 +21,16 @@ import { USER_COLL_NOT_FOUND, } from 'src/errors'; import * as E from 'fp-ts/Either'; -import { PublishedDocs } from './published-docs.model'; +import { PublishedDocs, PublishedDocsVersion } from './published-docs.model'; import { OffsetPaginationArgs } from 'src/types/input-types.args'; import { stringToJson } from 'src/utils'; import { UserCollectionService } from 'src/user-collection/user-collection.service'; import { TeamCollectionService } from 'src/team-collection/team-collection.service'; -import { GetPublishedDocsQueryDto, TreeLevel } from './published-docs.dto'; import { ConfigService } from '@nestjs/config'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; +import { CollectionFolder } from 'src/types/CollectionFolder'; +import { plainToInstance } from 'class-transformer'; +import { JsonValue } from '@prisma/client/runtime/client'; @Injectable() export class PublishedDocsService { @@ -36,18 +41,83 @@ export class PublishedDocsService { private readonly configService: ConfigService, ) {} + /** + * Get or generate slug for a collection + * - For existing published docs with the same collectionID, reuse the slug + * - For new collections, generate a new UUID-based slug + */ + private async getOrGenerateSlug( + collectionID: string, + workspaceType: WorkspaceType, + workspaceID: string, + ): Promise { + // Check if there's already a published doc for this collection + const existingDoc = await this.prisma.publishedDocs.findFirst({ + where: { + collectionID, + workspaceType, + workspaceID, + }, + orderBy: { + createdOn: 'asc', // Get the oldest one + }, + }); + + // If exists, reuse its slug + if (existingDoc) { + return existingDoc.slug; + } + + // Otherwise, generate a new slug using crypto.randomUUID() + return crypto.randomUUID(); + } + /** * Cast database PublishedDocs to GraphQL PublishedDocs */ - private cast(doc: DbPublishedDocs): PublishedDocs { + private cast( + doc: DbPublishedDocs, + versions: PublishedDocsVersion[] = [], + ): PublishedDocs { return { ...doc, + versions, documentTree: JSON.stringify(doc.documentTree), metadata: JSON.stringify(doc.metadata), - url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.id}/${doc.version}`, + environmentName: doc.environmentName ?? null, + environmentVariables: doc.environmentVariables + ? JSON.stringify(doc.environmentVariables) + : null, + url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.slug}/${doc.version}`, }; } + /** + * Fetch environment by ID based on workspace type + * Returns the environment name and variables, or an error if not found + */ + private async fetchEnvironment( + environmentID: string, + workspaceType: WorkspaceType, + workspaceID: string, + ): Promise> { + if (workspaceType === WorkspaceType.TEAM) { + const env = await this.prisma.teamEnvironment.findFirst({ + where: { id: environmentID, teamID: workspaceID }, + }); + if (!env) return E.left(PUBLISHED_DOCS_INVALID_ENVIRONMENT); + return E.right({ name: env.name, variables: env.variables }); + } else if (workspaceType === WorkspaceType.USER) { + const env = await this.prisma.userEnvironment.findFirst({ + where: { id: environmentID, userUid: workspaceID }, + }); + if (!env) return E.left(PUBLISHED_DOCS_INVALID_ENVIRONMENT); + return E.right({ name: env.name ?? '', variables: env.variables }); + } + + return E.left(PUBLISHED_DOCS_INVALID_ENVIRONMENT); + } + /** * Check if user has access to a team with specific roles */ @@ -195,6 +265,21 @@ export class PublishedDocsService { return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); } + /** + * (Field resolver) + * Get all versions of a published document by slug + */ + async getPublishedDocsVersions(slug: string) { + const allVersions = await this.prisma.publishedDocs.findMany({ + where: { slug }, + orderBy: [{ autoSync: 'desc' }, { createdOn: 'desc' }], + }); + + if (allVersions.length === 0) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + return E.right(allVersions.map((doc) => this.cast(doc))); + } + /** * Get a published document by ID */ @@ -215,19 +300,29 @@ export class PublishedDocsService { } /** - * Get a published document by ID for public access (unauthenticated) - * @param id - The ID of the published document - * @param query - Query parameters specifying tree level + * Get a published document by slug and version for public access (unauthenticated) + * @param slug - The slug of the published document + * @param version - The version of the published document */ - async getPublishedDocByIDPublic( - id: string, - query: GetPublishedDocsQueryDto, + async getPublishedDocBySlugPublic( + slug: string, + version: string | null, ): Promise> { + const allVersions = await this.getPublishedDocsVersions(slug); + if (E.isLeft(allVersions)) return E.left(allVersions.left); + const publishedDocs = await this.prisma.publishedDocs.findUnique({ - where: { id }, + where: { + slug_version: { + slug, + version: version ? version : allVersions.right[0].version, // If version is not specified, get the latest version + }, + }, }); if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + let docToReturn = publishedDocs; + // if autoSync is enabled, fetch from the collection directly if (publishedDocs.autoSync) { const collectionResult = @@ -235,12 +330,10 @@ export class PublishedDocsService { ? await this.userCollectionService.exportUserCollectionToJSONObject( publishedDocs.creatorUid, publishedDocs.collectionID, - query.tree === TreeLevel.FULL, ) : await this.teamCollectionService.exportCollectionToJSONObject( publishedDocs.workspaceID, publishedDocs.collectionID, - query.tree === TreeLevel.FULL, ); if (E.isLeft(collectionResult)) { @@ -258,15 +351,44 @@ export class PublishedDocsService { return E.left(collectionResult.left); } - return E.right( - this.cast({ - ...publishedDocs, - documentTree: JSON.parse(JSON.stringify(collectionResult.right)), - }), - ); + // Re-fetch environment if environmentID is set + let environmentName = publishedDocs.environmentName; + let environmentVariables = publishedDocs.environmentVariables; + + if (publishedDocs.environmentID) { + const workspaceID = + publishedDocs.workspaceType === WorkspaceType.USER + ? publishedDocs.creatorUid + : publishedDocs.workspaceID; + + const envResult = await this.fetchEnvironment( + publishedDocs.environmentID, + publishedDocs.workspaceType as WorkspaceType, + workspaceID, + ); + if (E.isLeft(envResult)) return E.left(envResult.left); + + if (E.isRight(envResult) && envResult.right) { + environmentName = envResult.right.name; + environmentVariables = envResult.right.variables; + } + } + + docToReturn = { + ...publishedDocs, + documentTree: collectionResult.right as unknown as JsonValue, + environmentName, + environmentVariables, + }; } - return E.right(this.cast(publishedDocs)); + return E.right( + plainToInstance( + PublishedDocs, + this.cast(docToReturn, allVersions.right), + { excludeExtraneousValues: true, enableCircularCheck: true }, + ), + ); } /** @@ -281,7 +403,7 @@ export class PublishedDocsService { if (docsToDelete.length > 0) { const idsToDelete = docsToDelete.map((doc) => doc.id); - this.prisma.publishedDocs.deleteMany({ + await this.prisma.publishedDocs.deleteMany({ where: { id: { in: idsToDelete } }, }); } @@ -383,7 +505,11 @@ export class PublishedDocsService { * @param args - Arguments for creating the published document * @param user - The user creating the published document */ - async createPublishedDoc(args: CreatePublishedDocsArgs, user: User) { + async createPublishedDoc( + args: CreatePublishedDocsArgs, + user: User, + retryCount: number = 0, + ): Promise> { try { // Validate workspace type and ID const workspaceValidation = await this.validateWorkspace(user, { @@ -408,25 +534,87 @@ export class PublishedDocsService { const metadata = stringToJson(args.metadata); if (E.isLeft(metadata)) return E.left(metadata.left); - // Create published document + // Get or generate slug for this collection + const workspaceID = + args.workspaceType === WorkspaceType.TEAM ? args.workspaceID : user.uid; + + // Get or generate slug + const slug = await this.getOrGenerateSlug( + args.collectionID, + args.workspaceType, + workspaceID, + ); + + let documentTree: CollectionFolder | null = null; + // If autoSync is disabled, fetch the latest collection data for snapshot + if (!args.autoSync) { + const collectionResult = + args.workspaceType === WorkspaceType.USER + ? await this.userCollectionService.exportUserCollectionToJSONObject( + user.uid, + args.collectionID, + ) + : await this.teamCollectionService.exportCollectionToJSONObject( + args.workspaceID, + args.collectionID, + ); + + if (E.isLeft(collectionResult)) { + return E.left(collectionResult.left); + } + + documentTree = collectionResult.right; + } + + // Fetch environment if environmentID is provided + let environmentName: string | null = null; + let environmentVariables: JsonValue | null = null; + + if (args.environmentID) { + const envResult = await this.fetchEnvironment( + args.environmentID, + args.workspaceType, + workspaceID, + ); + if (E.isLeft(envResult)) return E.left(envResult.left); + if (envResult.right) { + environmentName = envResult.right.name; + environmentVariables = envResult.right.variables; + } + } + + // Attempt to create the published document const newPublishedDoc = await this.prisma.publishedDocs.create({ data: { title: args.title, + slug: slug, collectionID: args.collectionID, creatorUid: user.uid, version: args.version, autoSync: args.autoSync, workspaceType: args.workspaceType, - workspaceID: - args.workspaceType === WorkspaceType.TEAM - ? args.workspaceID - : user.uid, + workspaceID: workspaceID, + documentTree: documentTree as unknown as JsonValue, metadata: metadata.right, + environmentID: args.environmentID ?? null, + environmentName, + environmentVariables, }, }); return E.right(this.cast(newPublishedDoc)); } catch (error) { + // Check if it's a unique constraint violation on [slug, version] + // Allow up to 3 total attempts (initial + 2 retries) + const maxRetries = 2; + if ( + error.code === PrismaError.UNIQUE_CONSTRAINT_VIOLATION && + retryCount < maxRetries + ) { + // Race condition detected: retry with fresh slug generation + return this.createPublishedDoc(args, user, retryCount + 1); + } + console.error('Error creating published document:', error); return E.left(PUBLISHED_DOCS_CREATION_FAILED); } @@ -464,6 +652,59 @@ export class PublishedDocsService { if (E.isLeft(metadata)) return E.left(metadata.left); } + // Determine documentTree based on autoSync value + let documentTree: CollectionFolder | null | undefined = undefined; // undefined = no change + + if (args.autoSync === true) { + // autoSync enabled → clear documentTree (will be generated dynamically) + documentTree = null; + } else if (args.autoSync === false && publishedDocs.autoSync === true) { + // Switching from autoSync true → false: generate a snapshot of the collection + const collectionResult = + publishedDocs.workspaceType === WorkspaceType.USER + ? await this.userCollectionService.exportUserCollectionToJSONObject( + publishedDocs.creatorUid, + publishedDocs.collectionID, + ) + : await this.teamCollectionService.exportCollectionToJSONObject( + publishedDocs.workspaceID, + publishedDocs.collectionID, + ); + + if (E.isLeft(collectionResult)) { + return E.left(collectionResult.left); + } + + documentTree = collectionResult.right; + } + + // Handle environment update if environmentID is provided + let environmentName: string | null | undefined = undefined; // undefined = no change + let environmentVariables: JsonValue | undefined = undefined; + let environmentID: string | null | undefined = undefined; + + if (args.environmentID !== undefined) { + if (args.environmentID === null) { + // Explicitly removing environment + environmentID = null; + environmentName = null; + environmentVariables = null; + } else { + // Fetch environment data + const envResult = await this.fetchEnvironment( + args.environmentID, + publishedDocs.workspaceType as WorkspaceType, + publishedDocs.workspaceID, + ); + if (E.isLeft(envResult)) return E.left(envResult.left); + if (envResult.right) { + environmentID = args.environmentID; + environmentName = envResult.right.name; + environmentVariables = envResult.right.variables; + } + } + } + // Update published document const updatedPublishedDoc = await this.prisma.publishedDocs.update({ where: { id }, @@ -471,8 +712,20 @@ export class PublishedDocsService { title: args.title, version: args.version, autoSync: args.autoSync, + documentTree: + documentTree !== undefined + ? (documentTree as unknown as JsonValue) + : undefined, metadata: metadata && E.isRight(metadata) ? metadata.right : undefined, + environmentID: + environmentID !== undefined ? environmentID : undefined, + environmentName: + environmentName !== undefined ? environmentName : undefined, + environmentVariables: + environmentVariables !== undefined + ? environmentVariables + : undefined, }, }); diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index b11b392e..3f4e6511 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -106,35 +106,32 @@ export class TeamCollectionService { * * @param teamID The Team ID * @param collectionID The Collection ID - * @param withChildren Whether to include child collections and their requests * @returns A JSON string containing all the contents of a collection */ async exportCollectionToJSONObject( teamID: string, collectionID: string, - withChildren: boolean = true, ): Promise | E.Left> { const collection = await this.getCollection(collectionID); if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID); const childrenCollectionObjects = []; - if (withChildren) { - const childrenCollection = await this.prisma.teamCollection.findMany({ - where: { - teamID, - parentID: collectionID, - }, - orderBy: { - orderIndex: 'asc', - }, - }); - for (const coll of childrenCollection) { - const result = await this.exportCollectionToJSONObject(teamID, coll.id); - if (E.isLeft(result)) return E.left(result.left); + const childrenCollection = await this.prisma.teamCollection.findMany({ + where: { + teamID, + parentID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); - childrenCollectionObjects.push(result.right); - } + for (const coll of childrenCollection) { + const result = await this.exportCollectionToJSONObject(teamID, coll.id); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); } const requests = await this.prisma.teamRequest.findMany({ diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts index 66dc179d..4ab6e615 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -853,41 +853,38 @@ export class UserCollectionService { * * @param userUID The User UID * @param collectionID The Collection ID - * @param withChildren Whether to include child collections and their requests * @returns A JSON string containing all the contents of a collection */ async exportUserCollectionToJSONObject( userUID: string, collectionID: string, - withChildren: boolean = true, ): Promise | E.Right> { // Get Collection details const collection = await this.getUserCollection(collectionID); if (E.isLeft(collection)) return E.left(collection.left); const childrenCollectionObjects: CollectionFolder[] = []; - if (withChildren) { - // Get all child collections whose parentID === collectionID - const childCollectionList = await this.prisma.userCollection.findMany({ - where: { - parentID: collectionID, - userUid: userUID, - }, - orderBy: { - orderIndex: 'asc', - }, - }); - // Create a list of child collection and request data ready for export - for (const coll of childCollectionList) { - const result = await this.exportUserCollectionToJSONObject( - userUID, - coll.id, - ); - if (E.isLeft(result)) return E.left(result.left); + // Get all child collections whose parentID === collectionID + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + parentID: collectionID, + userUid: userUID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); - childrenCollectionObjects.push(result.right); - } + // Create a list of child collection and request data ready for export + for (const coll of childCollectionList) { + const result = await this.exportUserCollectionToJSONObject( + userUID, + coll.id, + ); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); } // Fetch all child requests that belong to collectionID diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index ced36197..b86e4a59 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -533,7 +533,33 @@ "update_title": "Update Published Documentation", "url_copied": "URL copied to clipboard!", "view_published": "View Published Docs", - "view_title": "View Published Documentation" + "view_title": "Published Documentation Snapshot", + "versions": "Versions", + "create_new_version": "Create New Version", + "invalid_version": "Version must only contain alphanumeric characters, dots, and hyphens", + "snapshot_description": "This will snapshot the current documentation as this version", + "live": "Live", + "snapshot": "Snapshot", + "version_immutable": "Published versions are read-only snapshots", + "view_snapshot": "View Snapshot", + "current_version": "CURRENT", + "not_found": "Published documentation not found", + "no_doc_id": "No document ID provided", + "version_label": "v{version}", + "unpublish_version": "Unpublish this version", + "loading_snapshot": "Loading snapshot...", + "retry_snapshot": "Retry", + "sensitive_data_warning": "Please make sure no sensitive data is exposed in the published documentation.", + "snapshot_preview": "Snapshot Preview", + "snapshot_load_error": "Failed to load snapshot preview", + "snapshot_empty": "No requests or folders in this snapshot", + "snapshot_item_count": "{count} items", + "auto_sync_live_notice": "This version auto-syncs with the live collection", + "untitled_project": "Untitled Project", + "first_publish_hint": "Your documentation will be published as a live version that automatically stays in sync with your collection", + "environment": "Environment", + "no_environment": "No environment", + "environment_description": "Attach an environment to resolve variables in the published documentation" }, "request_opened_in_new_tab": "Request opened in new tab!", "response": { diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 59d84ef4..bc172409 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -56,11 +56,14 @@ declare module 'vue' { CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default'] CollectionsDocumentationCollectionPreview: typeof import('./components/collections/documentation/CollectionPreview.vue')['default'] CollectionsDocumentationCollectionStructure: typeof import('./components/collections/documentation/CollectionStructure.vue')['default'] + CollectionsDocumentationEnvironmentPicker: typeof import('./components/collections/documentation/EnvironmentPicker.vue')['default'] CollectionsDocumentationFolderItem: typeof import('./components/collections/documentation/FolderItem.vue')['default'] CollectionsDocumentationLazyDocumentationItem: typeof import('./components/collections/documentation/LazyDocumentationItem.vue')['default'] CollectionsDocumentationMarkdownEditor: typeof import('./components/collections/documentation/MarkdownEditor.vue')['default'] CollectionsDocumentationPreview: typeof import('./components/collections/documentation/Preview.vue')['default'] + CollectionsDocumentationPublishDocForm: typeof import('./components/collections/documentation/PublishDocForm.vue')['default'] CollectionsDocumentationPublishDocModal: typeof import('./components/collections/documentation/PublishDocModal.vue')['default'] + CollectionsDocumentationPublishDocSnapshotPreview: typeof import('./components/collections/documentation/PublishDocSnapshotPreview.vue')['default'] CollectionsDocumentationRequestItem: typeof import('./components/collections/documentation/RequestItem.vue')['default'] CollectionsDocumentationRequestPreview: typeof import('./components/collections/documentation/RequestPreview.vue')['default'] CollectionsDocumentationSectionsAuth: typeof import('./components/collections/documentation/sections/Auth.vue')['default'] @@ -70,6 +73,7 @@ declare module 'vue' { CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default'] CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default'] CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default'] + CollectionsDocumentationSnapshotPreview: typeof import('./components/collections/documentation/SnapshotPreview.vue')['default'] CollectionsEdit: typeof import('./components/collections/Edit.vue')['default'] CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default'] CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default'] @@ -166,6 +170,7 @@ declare module 'vue' { HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio'] HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] + HoppSmartSelectItem: typeof import('@hoppscotch/ui')['HoppSmartSelectItem'] HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper'] HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] @@ -231,15 +236,20 @@ declare module 'vue' { HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default'] HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] IconLucideActivity: typeof import('~icons/lucide/activity')['default'] + IconLucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default'] IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] + IconLucideBookOpen: typeof import('~icons/lucide/book-open')['default'] IconLucideBrush: typeof import('~icons/lucide/brush')['default'] + IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] + IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default'] IconLucideFileText: typeof import('~icons/lucide/file-text')['default'] + IconLucideFileX: typeof import('~icons/lucide/file-x')['default'] IconLucideFolder: typeof import('~icons/lucide/folder')['default'] IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] @@ -252,6 +262,7 @@ declare module 'vue' { IconLucideLock: typeof import('~icons/lucide/lock')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] + IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideTerminal: typeof import('~icons/lucide/terminal')['default'] diff --git a/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue b/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue index e8316e15..9431c1d0 100644 --- a/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue +++ b/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue @@ -2,14 +2,15 @@
@@ -28,7 +29,7 @@
@@ -97,10 +98,12 @@ const props = withDefaults( collection: HoppCollection initiallyExpanded?: boolean isDocModal?: boolean + compact?: boolean }>(), { initiallyExpanded: false, isDocModal: true, + compact: false, } ) diff --git a/packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue b/packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue new file mode 100644 index 00000000..041ddbb0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/EnvironmentPicker.vue @@ -0,0 +1,191 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue b/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue index 8d62885e..44a5e589 100644 --- a/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue +++ b/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue @@ -158,6 +158,7 @@
+
+
+ +
+ +
+ + + {{ t("documentation.publish.first_publish_hint") }} + +
+ + +
+ + + {{ t("documentation.publish.invalid_version") }} + + + {{ t("documentation.publish.snapshot_description") }} + +
+ + +
+ +
+ + {{ t("documentation.publish.auto_sync") }} + + + ({{ t("documentation.publish.auto_sync_description") }}) + +
+
+
+ + +
+ + {{ t("documentation.publish.environment") }} + +

+ {{ t("documentation.publish.environment_description") }} +

+

+ {{ t("documentation.publish.sensitive_data_warning") }} +

+ +
+ + +
+
+ + + +
+
+
+ + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue index cb7255f1..9021c6a9 100644 --- a/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue +++ b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue @@ -3,97 +3,41 @@ v-if="show" dialog :title="modalTitle" - styles="sm:max-w-2xl" + :styles="mode === 'view' ? 'sm:max-w-6xl' : 'sm:max-w-2xl'" @close="hideModal" >