diff --git a/apps/backend/prisma/migrations/20260517110249_add_engagement_events/migration.sql b/apps/backend/prisma/migrations/20260517110249_add_engagement_events/migration.sql new file mode 100644 index 0000000..9e6bb1a --- /dev/null +++ b/apps/backend/prisma/migrations/20260517110249_add_engagement_events/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "EventType" AS ENUM ('PROFILE_VIEW', 'CARD_VIEW', 'LINK_CLICK', 'FOLLOW_ATTEMPT', 'FOLLOW_SUCCESS', 'QR_SCAN', 'SHARE_ACTION', 'COPY_LINK'); + +-- CreateTable +CREATE TABLE "engagement_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "viewer_id" TEXT, + "card_id" TEXT, + "event_type" "EventType" NOT NULL, + "platform" TEXT, + "source" TEXT, + "ip_hash" TEXT, + "user_agent" TEXT, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "engagement_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "engagement_events_user_id_idx" ON "engagement_events"("user_id"); + +-- CreateIndex +CREATE INDEX "engagement_events_event_type_idx" ON "engagement_events"("event_type"); + +-- CreateIndex +CREATE INDEX "engagement_events_created_at_idx" ON "engagement_events"("created_at"); + +-- CreateIndex +CREATE INDEX "engagement_events_user_id_event_type_idx" ON "engagement_events"("user_id", "event_type"); + +-- AddForeignKey +ALTER TABLE "engagement_events" ADD CONSTRAINT "engagement_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 13dec57..db54530 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -30,18 +30,20 @@ model User { viewedCards CardView[] @relation("cardViewer") followLogs FollowLog[] + engagementEvents EngagementEvent[] + @@unique([provider, providerId]) @@map("users") } model PlatformLink { - id String @id @default(uuid()) - userId String @map("user_id") + id String @id @default(uuid()) + userId String @map("user_id") platform String username String url String - displayOrder Int @default(0) @map("display_order") - createdAt DateTime @default(now()) @map("created_at") + displayOrder Int @default(0) @map("display_order") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] @@ -50,12 +52,12 @@ model PlatformLink { } model Card { - id String @id @default(uuid()) - userId String @map("user_id") + id String @id @default(uuid()) + userId String @map("user_id") title String - isDefault Boolean @default(false) @map("is_default") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] @@ -96,17 +98,17 @@ model OAuthToken { model CardView { id String @id @default(uuid()) - cardId String? @map("card_id") // null = default profile view - ownerId String @map("owner_id") // card/profile owner - viewerId String? @map("viewer_id") // null = anonymous web viewer + cardId String? @map("card_id") // null = default profile view + ownerId String @map("owner_id") // card/profile owner + viewerId String? @map("viewer_id") // null = anonymous web viewer viewerIp String? @map("viewer_ip") viewerAgent String? @map("viewer_agent") - source String @default("qr") // "qr" | "link" | "web" | "app" + source String @default("qr") // "qr" | "link" | "web" | "app" createdAt DateTime @default(now()) @map("created_at") - card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) - owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade) - viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) + card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) + owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade) + viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) @@map("card_views") } @@ -116,11 +118,51 @@ model FollowLog { followerId String @map("follower_id") targetUsername String @map("target_username") platform String - status String @default("success") // "success" | "error" - layer String // "api" | "webview" | "link" + status String @default("success") // "success" | "error" + layer String // "api" | "webview" | "link" createdAt DateTime @default(now()) @map("created_at") follower User @relation(fields: [followerId], references: [id], onDelete: Cascade) @@map("follow_logs") } + +enum EventType { + PROFILE_VIEW + CARD_VIEW + LINK_CLICK + FOLLOW_ATTEMPT + FOLLOW_SUCCESS + QR_SCAN + SHARE_ACTION + COPY_LINK +} + +model EngagementEvent { + id String @id @default(uuid()) + + userId String @map("user_id") + viewerId String? @map("viewer_id") + + cardId String? @map("card_id") + + eventType EventType @map("event_type") + + platform String? + source String? + + ipHash String? @map("ip_hash") + userAgent String? @map("user_agent") + + metadata Json? + + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([eventType]) + @@index([createdAt]) + @@index([userId, eventType]) + @@map("engagement_events") +} diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index f60e613..8f4a093 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,58 +1,59 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; +import { trackEvent } from '../services/analytics/trackEvent.js'; type PublicProfileLink = { id: string; platform: string; - username: string; - url: string; - displayOrder: number; + username: string; + url: string; + displayOrder: number; } -type UsernamePublicProfileResponse = { - username: string; +type UsernamePublicProfileResponse = { + username: string; displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; + bio: string | null; + pronouns: string | null; + role: string | null; company: string | null; - avatarUrl: string | null; + avatarUrl: string | null; accentColor: string; links: PublicProfileLink[] -} +} type PublicProfileCardLink = { id: string; platform: string; - username: string; - url: string; + username: string; + url: string; } type CardPublicProfileResponse = { - id: string; - title: string; + id: string; + title: string; owner: { - username: string; - displayName: string; + username: string; + displayName: string; bio: string | null; avatarUrl: string | null; - accentColor: string; - }; + accentColor: string; + }; links: PublicProfileCardLink[] } type UsernameCardPublicProfileResponse = { - title: string; + title: string; owner: { - username: string; + username: string; displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; + bio: string | null; + pronouns: string | null; + role: string | null; company: string | null; - avatarUrl: string | null; + avatarUrl: string | null; accentColor: string; - }; + }; links: PublicProfileCardLink[] } @@ -97,17 +98,39 @@ export async function publicRoutes(app: FastifyInstance) { // Don't track if the owner is viewing their own profile if (viewerId !== user.id) { - // Background view tracking app.prisma.cardView.create({ data: { ownerId: user.id, - cardId: null, // this is a profile view, not a card view + cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: (request.query as any)?.source || 'link', }, }).catch(err => app.log.error('Failed to log view:', err)); + + trackEvent(app.prisma, { + userId: user.id, + + viewerId: viewerId || undefined, + + eventType: 'PROFILE_VIEW', + + source: (request.query as any)?.source || 'link', + + ip: request.ip || undefined, + + userAgent: + typeof request.headers['user-agent'] === 'string' + ? request.headers['user-agent'] + : undefined, + + metadata: { + username: user.username, + }, + }).catch(err => + app.log.error('Failed to track engagement event:', err) + ); } const response: UsernamePublicProfileResponse = { @@ -128,7 +151,7 @@ export async function publicRoutes(app: FastifyInstance) { })), } - return response; + return response; }); @@ -175,7 +198,7 @@ export async function publicRoutes(app: FastifyInstance) { })), } - return response; + return response; }); @@ -218,7 +241,7 @@ export async function publicRoutes(app: FastifyInstance) { viewerId = decoded.id; } } - } catch (e) {} + } catch (e) { } if (viewerId !== user.id) { app.prisma.cardView.create({ @@ -254,7 +277,7 @@ export async function publicRoutes(app: FastifyInstance) { displayOrder: cl.displayOrder, })), } - return response; + return response; }); // ─── QR Code Generation ─── diff --git a/apps/backend/src/services/analytics/trackEvent.ts b/apps/backend/src/services/analytics/trackEvent.ts new file mode 100644 index 0000000..e1a462d --- /dev/null +++ b/apps/backend/src/services/analytics/trackEvent.ts @@ -0,0 +1,58 @@ +import crypto from 'crypto'; +import { PrismaClient, Prisma } from '@prisma/client'; + +type TrackEventInput = { + userId: string; + + viewerId?: string; + cardId?: string; + + eventType: + | 'PROFILE_VIEW' + | 'CARD_VIEW' + | 'LINK_CLICK' + | 'FOLLOW_ATTEMPT' + | 'FOLLOW_SUCCESS' + | 'QR_SCAN' + | 'SHARE_ACTION' + | 'COPY_LINK'; + + platform?: string; + source?: string; + + ip?: string; + userAgent?: string; + + metadata?: Prisma.InputJsonValue; +}; + +export async function trackEvent( + prisma: PrismaClient, + data: TrackEventInput +) { + const ipHash = data.ip + ? crypto + .createHash('sha256') + .update(data.ip) + .digest('hex') + : null; + + return prisma.engagementEvent.create({ + data: { + userId: data.userId, + + viewerId: data.viewerId, + cardId: data.cardId, + + eventType: data.eventType, + + platform: data.platform, + source: data.source, + + ipHash, + userAgent: data.userAgent, + + metadata: data.metadata + } + }); +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0786787..ee87fde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: devcard-postgres restart: unless-stopped ports: - - '5432:5432' + - '5433:5432' environment: POSTGRES_USER: devcard POSTGRES_PASSWORD: devcard @@ -12,7 +12,7 @@ services: volumes: - devcard-pgdata:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U devcard'] + test: [ 'CMD-SHELL', 'pg_isready -U devcard' ] interval: 5s timeout: 5s retries: 5 @@ -26,7 +26,7 @@ services: volumes: - devcard-redis:/data healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: [ 'CMD', 'redis-cli', 'ping' ] interval: 5s timeout: 5s retries: 5 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5bcffdd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,334 @@ +{ + "name": "devcard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devcard", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "concurrently": "^9.2.1" + }, + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +}