视频15天保护期支持按用户豁免,仅站内生效且超管可开关
This commit is contained in:
parent
09328d776d
commit
53b19ccd5a
@ -85,7 +85,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
|
||||
test -f "/app/prisma/migrations/20260820_add_ding_workflow_snapshots/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql"
|
||||
test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260826_add_user_video_early_access/migration.sql"
|
||||
|
||||
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
|
||||
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
|
||||
@ -204,6 +205,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_ding_w
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260824_expand_ding_workflow_insights ./prisma/migrations/20260824_expand_ding_workflow_insights
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_workflow_demand_type ./prisma/migrations/20260826_add_ding_workflow_demand_type
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_user_video_early_access ./prisma/migrations/20260826_add_user_video_early_access
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
|
||||
|
||||
@ -22,6 +22,7 @@ export async function GET() {
|
||||
isActive: true, // 保留,向后兼容
|
||||
status: true, // 新增:用户状态(阶段一)
|
||||
canDownload: true,
|
||||
videoEarlyAccess: true, // 视频提前访问豁免(仅超管可开关)
|
||||
department: true, // 保留,向后兼容
|
||||
departmentId: true, // 新增:部门ID(阶段二)
|
||||
managedDepartment: true, // 管理员负责的部门
|
||||
|
||||
64
app/api/admin/users/toggle-video-early-access/route.ts
Normal file
64
app/api/admin/users/toggle-video-early-access/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// 切换用户「视频提前访问」豁免API(跳过视频类素材 15 天保护期)
|
||||
// 仅超级管理员可操作,与"设为管理员"同级的高敏感授权。
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
// POST - 切换视频提前访问豁免(仅超级管理员)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !(session.user as any).id) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(session.user as any).isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '仅超级管理员可操作' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { userId } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少用户ID' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { videoEarlyAccess: !user.videoEarlyAccess },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: updatedUser.videoEarlyAccess
|
||||
? '已开启视频提前访问(跳过 15 天保护期)'
|
||||
: '已关闭视频提前访问(恢复 15 天保护期)',
|
||||
videoEarlyAccess: updatedUser.videoEarlyAccess,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling video early access:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
|
||||
import { isWebFileLike } from '@/lib/web-file';
|
||||
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
|
||||
import { DownloadErrorCode } from '@/lib/download-errors';
|
||||
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
|
||||
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
|
||||
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
|
||||
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard';
|
||||
import {
|
||||
@ -308,6 +308,8 @@ export async function GET(
|
||||
const videoPreviewPolicy = getVideoPreviewPolicyFields(
|
||||
(material as any).materialType,
|
||||
(material as any).createdAt,
|
||||
new Date(),
|
||||
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
|
||||
);
|
||||
|
||||
const buildVideoPreviewBlockedResponse = () =>
|
||||
|
||||
@ -24,6 +24,7 @@ import { resolveStoredUploadPath } from '@/lib/upload-paths';
|
||||
import {
|
||||
getVideoDownloadAvailability,
|
||||
getVideoDownloadUnavailableMessage,
|
||||
userHasVideoEarlyAccess,
|
||||
} from '@/lib/video-download-policy';
|
||||
import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
|
||||
|
||||
@ -101,6 +102,8 @@ export async function GET(request: NextRequest) {
|
||||
const videoDownloadAvailability = getVideoDownloadAvailability(
|
||||
material.materialType,
|
||||
material.createdAt,
|
||||
new Date(),
|
||||
{ bypassWaitPeriod: userHasVideoEarlyAccess(session.user) },
|
||||
);
|
||||
if (!videoDownloadAvailability.allowed) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
import { getMaterialCoverUrl } from '@/lib/product-package';
|
||||
import { isProductPackageType } from '@/lib/material-type-mapper';
|
||||
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
|
||||
import { applyVideoPreviewPolicy } from '@/lib/video-download-policy';
|
||||
import { applyVideoPreviewPolicy, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
|
||||
|
||||
// 🔒 R45修复:数据库查询限制常量(防止OOM)
|
||||
const MAX_PAGE_SIZE = 100; // 单页最大100条记录
|
||||
@ -845,7 +845,7 @@ export async function GET(request: NextRequest) {
|
||||
assets: formattedAssets,
|
||||
};
|
||||
|
||||
return applyVideoPreviewPolicy(rewrittenMaterial);
|
||||
return applyVideoPreviewPolicy(rewrittenMaterial, new Date(), { bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) });
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@ -15,7 +15,7 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
|
||||
import { canManageMaterial } from '@/lib/material-authorization';
|
||||
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
|
||||
import { DownloadErrorCode } from '@/lib/download-errors';
|
||||
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
|
||||
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
|
||||
|
||||
// 解析 displayImages 字段
|
||||
function parseDisplayImages(displayImages: any): string[] {
|
||||
@ -160,6 +160,8 @@ export async function GET(request: NextRequest) {
|
||||
const videoPreviewPolicy = getVideoPreviewPolicyFields(
|
||||
(material as any).materialType,
|
||||
(material as any).createdAt,
|
||||
new Date(),
|
||||
{ bypassWaitPeriod: userHasVideoEarlyAccess(session?.user) },
|
||||
);
|
||||
|
||||
if (videoPreviewPolicy.videoPreviewBlocked) {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// LEGACY_READ_ONLY: 旧域名仅用于代理读取历史素材,禁止作为新写入目标。
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { DownloadErrorCode } from '@/lib/download-errors'
|
||||
import {
|
||||
assertProxyMediaFetchTargetIsSafe,
|
||||
@ -12,6 +14,7 @@ import {
|
||||
safeProxyMediaUrlForLog,
|
||||
} from '@/lib/proxy-media-security'
|
||||
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
|
||||
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@ -154,10 +157,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
if (isVideoMediaPath(targetUrl.pathname)) {
|
||||
const blockedPreview = await findBlockedVideoPreviewByUrl([
|
||||
targetUrl.toString(),
|
||||
targetUrl.pathname,
|
||||
])
|
||||
// 懒获取登录态:豁免用户经数据接口拿到的播放地址必须能通过代理层,否则自相矛盾。
|
||||
const videoSession = await getServerSession(authOptions)
|
||||
const blockedPreview = await findBlockedVideoPreviewByUrl(
|
||||
[
|
||||
targetUrl.toString(),
|
||||
targetUrl.pathname,
|
||||
],
|
||||
new Date(),
|
||||
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
|
||||
)
|
||||
|
||||
if (blockedPreview) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import { DownloadErrorCode } from '@/lib/download-errors'
|
||||
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'
|
||||
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
|
||||
import { userHasVideoEarlyAccess } from '@/lib/video-download-policy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@ -265,12 +266,18 @@ async function handleFileRequest(
|
||||
}
|
||||
|
||||
if (isVideoMediaPath(normalizedRelativePath)) {
|
||||
const blockedPreview = await findBlockedVideoPreviewByUrl([
|
||||
request.url,
|
||||
`/api/uploads/${normalizedRelativePath}`,
|
||||
`/uploads/${normalizedRelativePath}`,
|
||||
normalizedRelativePath,
|
||||
])
|
||||
// 懒获取登录态:只有视频文件才需要判定 15 天保护期豁免。
|
||||
const videoSession = await getServerSession(authOptions)
|
||||
const blockedPreview = await findBlockedVideoPreviewByUrl(
|
||||
[
|
||||
request.url,
|
||||
`/api/uploads/${normalizedRelativePath}`,
|
||||
`/uploads/${normalizedRelativePath}`,
|
||||
normalizedRelativePath,
|
||||
],
|
||||
new Date(),
|
||||
{ bypassWaitPeriod: userHasVideoEarlyAccess(videoSession?.user) },
|
||||
)
|
||||
|
||||
if (blockedPreview) {
|
||||
return headOnly
|
||||
|
||||
@ -52,7 +52,9 @@ describe('video download policy integration', () => {
|
||||
const routePath = path.join(process.cwd(), 'app/api/materials/route.ts')
|
||||
const content = fs.readFileSync(routePath, 'utf8')
|
||||
|
||||
expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial)')
|
||||
// 列表必须应用策略,且豁免标记来自当前登录用户(站内豁免,外部接口不传)。
|
||||
expect(content).toContain('applyVideoPreviewPolicy(rewrittenMaterial')
|
||||
expect(content).toContain('bypassWaitPeriod: userHasVideoEarlyAccess(')
|
||||
})
|
||||
|
||||
test('本地上传文件代理应拦截等待期内的视频预览文件', () => {
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
getVideoDownloadAvailability,
|
||||
getVideoDownloadUnavailableMessage,
|
||||
getVideoPreviewPolicyFields,
|
||||
userHasVideoEarlyAccess,
|
||||
VIDEO_DOWNLOAD_WAIT_DAYS,
|
||||
} from '@/lib/video-download-policy'
|
||||
import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors'
|
||||
@ -114,3 +115,72 @@ describe('video download policy', () => {
|
||||
expect(material.videoCoverUrl).toBe('https://example.com/cover.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('video download policy — 15 天保护期豁免', () => {
|
||||
const now = new Date('2026-05-25T12:00:00.000Z')
|
||||
|
||||
test('豁免用户不受保护期限制(直接放行)', () => {
|
||||
const availability = getVideoDownloadAvailability(
|
||||
'淘系-主图视频',
|
||||
'2026-05-20T12:00:00.000Z',
|
||||
now,
|
||||
{ bypassWaitPeriod: true },
|
||||
)
|
||||
|
||||
expect(availability).toEqual({ allowed: true })
|
||||
})
|
||||
|
||||
test('豁免时预览字段全部放行', () => {
|
||||
const fields = getVideoPreviewPolicyFields(
|
||||
'逛逛视频',
|
||||
'2026-05-25T00:00:00.000Z',
|
||||
now,
|
||||
{ bypassWaitPeriod: true },
|
||||
)
|
||||
|
||||
expect(fields.videoPreviewBlocked).toBe(false)
|
||||
expect(fields.videoPreviewUnavailableReason).toBeNull()
|
||||
})
|
||||
|
||||
test('豁免时 URL 不被抹除', () => {
|
||||
const result = applyVideoPreviewPolicy(
|
||||
{
|
||||
materialType: '淘系-主图视频',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
videoSourceUrl: 'https://example.com/video.mp4',
|
||||
videoPreview540Url: 'https://example.com/540.mp4',
|
||||
},
|
||||
now,
|
||||
{ bypassWaitPeriod: true },
|
||||
)
|
||||
|
||||
expect(result.videoSourceUrl).toBe('https://example.com/video.mp4')
|
||||
expect(result.videoPreview540Url).toBe('https://example.com/540.mp4')
|
||||
expect(result.videoPreviewBlocked).toBe(false)
|
||||
})
|
||||
|
||||
test('未豁免时行为与旧逻辑完全一致(默认参数不变)', () => {
|
||||
const withOptions = getVideoDownloadAvailability(
|
||||
'淘系-主图视频',
|
||||
'2026-05-20T12:00:00.000Z',
|
||||
now,
|
||||
{ bypassWaitPeriod: false },
|
||||
)
|
||||
const withoutOptions = getVideoDownloadAvailability(
|
||||
'淘系-主图视频',
|
||||
'2026-05-20T12:00:00.000Z',
|
||||
now,
|
||||
)
|
||||
|
||||
expect(withOptions).toEqual(withoutOptions)
|
||||
expect(withOptions.allowed).toBe(false)
|
||||
})
|
||||
|
||||
test('userHasVideoEarlyAccess 只认显式 true', () => {
|
||||
expect(userHasVideoEarlyAccess({ videoEarlyAccess: true })).toBe(true)
|
||||
expect(userHasVideoEarlyAccess({ videoEarlyAccess: false })).toBe(false)
|
||||
expect(userHasVideoEarlyAccess({})).toBe(false)
|
||||
expect(userHasVideoEarlyAccess(undefined)).toBe(false)
|
||||
expect(userHasVideoEarlyAccess(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -154,6 +154,7 @@ export const authOptions: NextAuthOptions = {
|
||||
console.error('读取品牌中心访问范围失败,已按无权限处理:', error)
|
||||
return false
|
||||
})
|
||||
token.videoEarlyAccess = (user as any).videoEarlyAccess === true
|
||||
} else if (token.id) {
|
||||
const lastCheckedAt = Number((token as any).authCheckedAt || 0)
|
||||
const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean'
|
||||
@ -168,6 +169,7 @@ export const authOptions: NextAuthOptions = {
|
||||
department: true,
|
||||
managedDepartment: true,
|
||||
isSuperAdmin: true,
|
||||
videoEarlyAccess: true,
|
||||
},
|
||||
})
|
||||
|
||||
@ -182,6 +184,7 @@ export const authOptions: NextAuthOptions = {
|
||||
token.isSuperAdmin = currentUser.isSuperAdmin
|
||||
token.department = currentUser.department || ''
|
||||
token.managedDepartment = currentUser.managedDepartment || null
|
||||
token.videoEarlyAccess = currentUser.videoEarlyAccess === true
|
||||
token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), {
|
||||
role: currentUser.role,
|
||||
isSuperAdmin: currentUser.isSuperAdmin,
|
||||
@ -209,6 +212,7 @@ export const authOptions: NextAuthOptions = {
|
||||
(session.user as any).department = (token as any).department || '';
|
||||
(session.user as any).managedDepartment = (token as any).managedDepartment || null;
|
||||
(session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true;
|
||||
(session.user as any).videoEarlyAccess = (token as any).videoEarlyAccess === true;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
@ -3,6 +3,14 @@ import { INSTALL_VIDEO_TYPE, isVideoMaterialType, normalizeVideoMaterialType } f
|
||||
export const VIDEO_DOWNLOAD_WAIT_DAYS = 15
|
||||
export const VIDEO_DOWNLOAD_WAIT_MS = VIDEO_DOWNLOAD_WAIT_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 15 天保护期的显式豁免。仅站内用户经超级管理员开通后生效;
|
||||
* 外部 apiKey 接口不传此项,维持完整保护期语义。
|
||||
*/
|
||||
export interface VideoPolicyOptions {
|
||||
bypassWaitPeriod?: boolean
|
||||
}
|
||||
|
||||
export interface VideoDownloadAvailability {
|
||||
allowed: boolean
|
||||
reason?: 'VIDEO_WAIT_PERIOD'
|
||||
@ -18,11 +26,20 @@ export interface VideoPreviewPolicyFields {
|
||||
videoPreviewUnavailableMessage?: string | null
|
||||
}
|
||||
|
||||
export function userHasVideoEarlyAccess(user?: unknown): boolean {
|
||||
return Boolean((user as { videoEarlyAccess?: boolean } | null)?.videoEarlyAccess)
|
||||
}
|
||||
|
||||
export function getVideoDownloadAvailability(
|
||||
materialType?: string | null,
|
||||
createdAt?: Date | string | null,
|
||||
now: Date = new Date(),
|
||||
options?: VideoPolicyOptions,
|
||||
): VideoDownloadAvailability {
|
||||
if (options?.bypassWaitPeriod) {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
const normalizedType = normalizeVideoMaterialType(materialType)
|
||||
|
||||
if (!isVideoMaterialType(normalizedType) || normalizedType === INSTALL_VIDEO_TYPE) {
|
||||
@ -62,8 +79,9 @@ export function getVideoPreviewPolicyFields(
|
||||
materialType?: string | null,
|
||||
createdAt?: Date | string | null,
|
||||
now: Date = new Date(),
|
||||
options?: VideoPolicyOptions,
|
||||
): VideoPreviewPolicyFields {
|
||||
const availability = getVideoDownloadAvailability(materialType, createdAt, now)
|
||||
const availability = getVideoDownloadAvailability(materialType, createdAt, now, options)
|
||||
|
||||
if (availability.allowed) {
|
||||
return {
|
||||
@ -87,11 +105,13 @@ export function getVideoPreviewPolicyFields(
|
||||
export function applyVideoPreviewPolicy<T extends Record<string, any>>(
|
||||
material: T,
|
||||
now: Date = new Date(),
|
||||
options?: VideoPolicyOptions,
|
||||
): T & VideoPreviewPolicyFields {
|
||||
const policyFields = getVideoPreviewPolicyFields(
|
||||
material?.materialType,
|
||||
material?.createdAt,
|
||||
now,
|
||||
options,
|
||||
)
|
||||
const nextMaterial: any = {
|
||||
...material,
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
getVideoDownloadAvailability,
|
||||
getVideoPreviewUnavailableMessage,
|
||||
type VideoDownloadAvailability,
|
||||
type VideoPolicyOptions,
|
||||
} from './video-download-policy'
|
||||
|
||||
const VIDEO_MEDIA_EXTENSIONS = ['.mp4', '.webm', '.ogg']
|
||||
@ -158,6 +159,7 @@ async function findVideoMaterialByUrlCandidates(
|
||||
export async function findBlockedVideoPreviewByUrl(
|
||||
values: Array<string | URL | null | undefined>,
|
||||
now: Date = new Date(),
|
||||
options?: VideoPolicyOptions,
|
||||
): Promise<BlockedVideoPreviewMatch | null> {
|
||||
const candidates = buildVideoUrlCandidates(values)
|
||||
const material = await findVideoMaterialByUrlCandidates(candidates)
|
||||
@ -170,6 +172,7 @@ export async function findBlockedVideoPreviewByUrl(
|
||||
material.materialType,
|
||||
material.createdAt,
|
||||
now,
|
||||
options,
|
||||
)
|
||||
|
||||
if (availability.allowed) {
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- 视频提前访问豁免:默认关闭,仅超级管理员可在用户管理中为个别用户开启。
|
||||
ALTER TABLE "users" ADD COLUMN "video_early_access" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -18,6 +18,7 @@ model User {
|
||||
role Role @default(USER)
|
||||
isActive Boolean @default(false)
|
||||
canDownload Boolean @default(true)
|
||||
videoEarlyAccess Boolean @default(false) @map("video_early_access")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
department String @default("")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user