feat(server): system_provider_configs and character_covers (#905)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE "character_covers" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"character_id" text NOT NULL,
|
||||
"foreground_url" text NOT NULL,
|
||||
"background_url" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "system_provider_configs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"definition_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"validated" boolean DEFAULT false NOT NULL,
|
||||
"validation_bypassed" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_settings_provider_configs" RENAME TO "user_provider_configs";--> statement-breakpoint
|
||||
ALTER TABLE "character_bookmarks" RENAME TO "user_character_bookmarks";--> statement-breakpoint
|
||||
ALTER TABLE "character_likes" RENAME TO "user_character_likes";--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" DROP CONSTRAINT "character_bookmarks_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" DROP CONSTRAINT "character_bookmarks_character_id_characters_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" DROP CONSTRAINT "character_likes_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" DROP CONSTRAINT "character_likes_character_id_characters_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_provider_configs" DROP CONSTRAINT "user_settings_provider_configs_owner_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" DROP CONSTRAINT "character_bookmarks_user_id_character_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" DROP CONSTRAINT "character_likes_user_id_character_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" ADD CONSTRAINT "user_character_bookmarks_user_id_character_id_pk" PRIMARY KEY("user_id","character_id");--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" ADD CONSTRAINT "user_character_likes_user_id_character_id_pk" PRIMARY KEY("user_id","character_id");--> statement-breakpoint
|
||||
ALTER TABLE "character_covers" ADD CONSTRAINT "character_covers_character_id_characters_id_fk" FOREIGN KEY ("character_id") REFERENCES "public"."characters"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" ADD CONSTRAINT "user_character_bookmarks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_character_bookmarks" ADD CONSTRAINT "user_character_bookmarks_character_id_characters_id_fk" FOREIGN KEY ("character_id") REFERENCES "public"."characters"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" ADD CONSTRAINT "user_character_likes_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_character_likes" ADD CONSTRAINT "user_character_likes_character_id_characters_id_fk" FOREIGN KEY ("character_id") REFERENCES "public"."characters"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_provider_configs" ADD CONSTRAINT "user_provider_configs_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "characters" DROP COLUMN "character_avatar_url";--> statement-breakpoint
|
||||
ALTER TABLE "characters" DROP COLUMN "cover_background_url";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1767692253453,
|
||||
"tag": "0004_faithful_lilith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1767792829241,
|
||||
"tag": "0005_true_namor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,6 +57,9 @@ export const InsertCharacterCapabilitySchema = createInsertSchema(schema.charact
|
||||
export const CharacterI18nSchema = createSelectSchema(schema.characterI18n)
|
||||
export const InsertCharacterI18nSchema = createInsertSchema(schema.characterI18n)
|
||||
|
||||
export const CharacterCoverSchema = createSelectSchema(schema.characterCovers)
|
||||
export const InsertCharacterCoverSchema = createInsertSchema(schema.characterCovers)
|
||||
|
||||
export const CharacterPromptSchema = createSelectSchema(schema.characterPrompts)
|
||||
export const InsertCharacterPromptSchema = createInsertSchema(schema.characterPrompts)
|
||||
|
||||
@@ -70,11 +73,12 @@ export const CreateCharacterSchema = object({
|
||||
creatorId: optional(string()),
|
||||
ownerId: optional(string()),
|
||||
avatarUrl: optional(string()),
|
||||
characterAvatarUrl: optional(string()),
|
||||
coverBackgroundUrl: optional(string()),
|
||||
creatorRole: optional(string()),
|
||||
priceCredit: optional(string()),
|
||||
}),
|
||||
cover: optional(createInsertSchema(schema.characterCovers, {
|
||||
characterId: optional(string()),
|
||||
})),
|
||||
capabilities: optional(array(createInsertSchema(schema.characterCapabilities, {
|
||||
characterId: optional(string()),
|
||||
type: CharacterCapabilityTypeSchema,
|
||||
@@ -99,8 +103,6 @@ export const UpdateCharacterSchema = createInsertSchema(schema.character, {
|
||||
version: optional(string()),
|
||||
coverUrl: optional(string()),
|
||||
avatarUrl: optional(string()),
|
||||
characterAvatarUrl: optional(string()),
|
||||
coverBackgroundUrl: optional(string()),
|
||||
creatorRole: optional(string()),
|
||||
priceCredit: optional(string()),
|
||||
creatorId: optional(string()),
|
||||
|
||||
@@ -3,8 +3,11 @@ import { boolean, object, optional, record, string } from 'valibot'
|
||||
|
||||
import * as schema from '../schemas/providers'
|
||||
|
||||
export const ProviderConfigSchema = createSelectSchema(schema.providerConfigs)
|
||||
export const InsertProviderConfigSchema = createInsertSchema(schema.providerConfigs)
|
||||
export const UserProviderConfigSchema = createSelectSchema(schema.userProviderConfigs)
|
||||
export const InsertUserProviderConfigSchema = createInsertSchema(schema.userProviderConfigs)
|
||||
|
||||
export const SystemProviderConfigSchema = createSelectSchema(schema.systemProviderConfigs)
|
||||
export const InsertSystemProviderConfigSchema = createInsertSchema(schema.systemProviderConfigs)
|
||||
|
||||
export const CreateProviderConfigSchema = object({
|
||||
definitionId: string(),
|
||||
|
||||
@@ -64,10 +64,11 @@ describe('characterRoutes', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('post / should create character', async () => {
|
||||
it('post / should create character with cover', async () => {
|
||||
const payload = {
|
||||
character: { version: '1', coverUrl: 'url', characterId: 'cid' },
|
||||
i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }],
|
||||
cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' },
|
||||
}
|
||||
|
||||
const res = await app.fetch(new Request('http://localhost/', {
|
||||
@@ -79,6 +80,9 @@ describe('characterRoutes', () => {
|
||||
expect(res.status).toBe(201)
|
||||
const data = await res.json()
|
||||
expect(data.id).toBeDefined()
|
||||
|
||||
const char = await characterService.findById(data.id)
|
||||
expect(char?.cover?.foregroundUrl).toBe('fg')
|
||||
})
|
||||
|
||||
it('get / should return created character', async () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('providerRoutes', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('get / should return empty list initially', async () => {
|
||||
it('get / should return empty list initially (only user configs, system configs tested later)', async () => {
|
||||
const res = await app.fetch(new Request('http://localhost/'), { user: testUser } as any)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
@@ -83,26 +83,43 @@ describe('providerRoutes', () => {
|
||||
expect(data.name).toBe('My OpenAI')
|
||||
})
|
||||
|
||||
it('get / should return created provider', async () => {
|
||||
it('get / should return unified list (user + system)', async () => {
|
||||
// Create a system config directly in DB
|
||||
await db.insert(schema.systemProviderConfigs).values({
|
||||
id: 'sys-1',
|
||||
definitionId: 'anthropic',
|
||||
name: 'System Anthropic',
|
||||
config: { apiKey: 'sys-sk' },
|
||||
})
|
||||
|
||||
const res = await app.fetch(new Request('http://localhost/'), { user: testUser } as any)
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.length).toBe(1)
|
||||
expect(data[0].definitionId).toBe('openai')
|
||||
expect(data.length).toBe(2)
|
||||
expect(data.some((p: any) => p.isSystem === true)).toBe(true)
|
||||
expect(data.some((p: any) => p.isSystem === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('get /:id should return specific provider', async () => {
|
||||
const providers = await providerService.findByOwnerId(testUser.id)
|
||||
it('get /:id should return specific provider (user or system)', async () => {
|
||||
const providers = await providerService.findUserConfigsByOwnerId(testUser.id)
|
||||
const providerId = providers[0].id
|
||||
|
||||
const res = await app.fetch(new Request(`http://localhost/${providerId}`), { user: testUser } as any)
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.id).toBe(providerId)
|
||||
expect(data.isSystem).toBe(false)
|
||||
|
||||
// Test system config access
|
||||
const resSys = await app.fetch(new Request('http://localhost/sys-1'), { user: testUser } as any)
|
||||
expect(resSys.status).toBe(200)
|
||||
const dataSys = await resSys.json()
|
||||
expect(dataSys.id).toBe('sys-1')
|
||||
expect(dataSys.isSystem).toBe(true)
|
||||
})
|
||||
|
||||
it('patch /:id should update provider config', async () => {
|
||||
const providers = await providerService.findByOwnerId(testUser.id)
|
||||
const providers = await providerService.findUserConfigsByOwnerId(testUser.id)
|
||||
const providerId = providers[0].id
|
||||
|
||||
const res = await app.fetch(new Request(`http://localhost/${providerId}`, {
|
||||
@@ -112,7 +129,7 @@ describe('providerRoutes', () => {
|
||||
}), { user: testUser } as any)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const updated = await providerService.findById(providerId)
|
||||
const updated = await providerService.findUserConfigById(providerId)
|
||||
expect(updated?.name).toBe('Updated Name')
|
||||
})
|
||||
|
||||
@@ -124,7 +141,7 @@ describe('providerRoutes', () => {
|
||||
email: 'other@example.com',
|
||||
}).returning()
|
||||
|
||||
const providers = await providerService.findByOwnerId(testUser.id)
|
||||
const providers = await providerService.findUserConfigsByOwnerId(testUser.id)
|
||||
const providerId = providers[0].id
|
||||
|
||||
const res = await app.fetch(new Request(`http://localhost/${providerId}`, {
|
||||
@@ -137,7 +154,7 @@ describe('providerRoutes', () => {
|
||||
})
|
||||
|
||||
it('delete /:id should soft delete provider', async () => {
|
||||
const providers = await providerService.findByOwnerId(testUser.id)
|
||||
const providers = await providerService.findUserConfigsByOwnerId(testUser.id)
|
||||
const providerId = providers[0].id
|
||||
|
||||
const res = await app.fetch(new Request(`http://localhost/${providerId}`, {
|
||||
@@ -145,7 +162,7 @@ describe('providerRoutes', () => {
|
||||
}), { user: testUser } as any)
|
||||
|
||||
expect(res.status).toBe(204)
|
||||
const deleted = await providerService.findById(providerId)
|
||||
const deleted = await providerService.findUserConfigById(providerId)
|
||||
expect(deleted).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,14 @@ export function createProviderRoutes(providerService: ProviderService) {
|
||||
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user')!
|
||||
const providers = await providerService.findByOwnerId(user.id)
|
||||
const providers = await providerService.findAll(user.id)
|
||||
return c.json(providers)
|
||||
})
|
||||
|
||||
.get('/:id', async (c) => {
|
||||
const user = c.get('user')!
|
||||
const id = c.req.param('id')
|
||||
const provider = await providerService.findById(id)
|
||||
const provider = await providerService.findById(id, user.id)
|
||||
if (!provider)
|
||||
throw createNotFoundError()
|
||||
|
||||
@@ -36,10 +37,10 @@ export function createProviderRoutes(providerService: ProviderService) {
|
||||
throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)
|
||||
}
|
||||
|
||||
const provider = await providerService.create({
|
||||
const provider = await providerService.createUserConfig({
|
||||
...result.output,
|
||||
ownerId: user.id,
|
||||
})
|
||||
} as any)
|
||||
|
||||
return c.json(provider, 201)
|
||||
})
|
||||
@@ -54,13 +55,13 @@ export function createProviderRoutes(providerService: ProviderService) {
|
||||
throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)
|
||||
}
|
||||
|
||||
const existing = await providerService.findById(id)
|
||||
const existing = await providerService.findUserConfigById(id)
|
||||
if (!existing)
|
||||
throw createNotFoundError()
|
||||
if (existing.ownerId !== user.id)
|
||||
throw createForbiddenError()
|
||||
|
||||
const updated = await providerService.update(id, result.output)
|
||||
const updated = await providerService.updateUserConfig(id, result.output)
|
||||
return c.json(updated)
|
||||
})
|
||||
|
||||
@@ -68,13 +69,13 @@ export function createProviderRoutes(providerService: ProviderService) {
|
||||
const user = c.get('user')!
|
||||
const id = c.req.param('id')
|
||||
|
||||
const existing = await providerService.findById(id)
|
||||
const existing = await providerService.findUserConfigById(id)
|
||||
if (!existing)
|
||||
throw createNotFoundError()
|
||||
if (existing.ownerId !== user.id)
|
||||
throw createForbiddenError()
|
||||
|
||||
await providerService.delete(id)
|
||||
await providerService.deleteUserConfig(id)
|
||||
return c.body(null, 204)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import type { AvatarModelConfig } from '../types/character-avatar-model'
|
||||
import type { CharacterCapabilityConfig } from '../types/character-capability'
|
||||
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { integer, jsonb, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
|
||||
import { nanoid } from '../utils/id'
|
||||
import { user } from './accounts'
|
||||
import { characterBookmarks, characterLikes } from './user-character'
|
||||
|
||||
export const character = pgTable(
|
||||
'characters',
|
||||
@@ -22,8 +23,6 @@ export const character = pgTable(
|
||||
ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
characterId: text('character_id').notNull(),
|
||||
avatarUrl: text('avatar_url'),
|
||||
characterAvatarUrl: text('character_avatar_url'),
|
||||
coverBackgroundUrl: text('cover_background_url'),
|
||||
creatorRole: text('creator_role'),
|
||||
priceCredit: text('price_credit').default('0').notNull(),
|
||||
|
||||
@@ -41,6 +40,23 @@ export const character = pgTable(
|
||||
export type Character = InferSelectModel<typeof character>
|
||||
export type NewCharacter = InferInsertModel<typeof character>
|
||||
|
||||
export const characterCovers = pgTable(
|
||||
'character_covers',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
|
||||
foregroundUrl: text('foreground_url').notNull(),
|
||||
backgroundUrl: text('background_url').notNull(),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
},
|
||||
)
|
||||
export type CharacterCover = InferSelectModel<typeof characterCovers>
|
||||
export type NewCharacterCover = InferInsertModel<typeof characterCovers>
|
||||
|
||||
export const avatarModel = pgTable(
|
||||
'avatar_model',
|
||||
{
|
||||
@@ -124,30 +140,6 @@ export const characterPrompts = pgTable(
|
||||
},
|
||||
)
|
||||
|
||||
export const characterLikes = pgTable(
|
||||
'character_likes',
|
||||
{
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
primaryKey({ columns: [table.userId, table.characterId] }),
|
||||
],
|
||||
)
|
||||
|
||||
export const characterBookmarks = pgTable(
|
||||
'character_bookmarks',
|
||||
{
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
primaryKey({ columns: [table.userId, table.characterId] }),
|
||||
],
|
||||
)
|
||||
|
||||
export type CharacterPrompt = InferSelectModel<typeof characterPrompts>
|
||||
export type NewCharacterPrompt = InferInsertModel<typeof characterPrompts>
|
||||
|
||||
@@ -164,6 +156,24 @@ export const characterRelations = relations(
|
||||
fields: [character.ownerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
creator: one(user, {
|
||||
fields: [character.creatorId],
|
||||
references: [user.id],
|
||||
}),
|
||||
cover: one(characterCovers, {
|
||||
fields: [character.id],
|
||||
references: [characterCovers.characterId],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export const characterCoversRelations = relations(
|
||||
characterCovers,
|
||||
({ one }) => ({
|
||||
character: one(character, {
|
||||
fields: [characterCovers.characterId],
|
||||
references: [character.id],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -206,31 +216,3 @@ export const characterPromptsRelations = relations(
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export const characterLikesRelations = relations(
|
||||
characterLikes,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [characterLikes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
character: one(character, {
|
||||
fields: [characterLikes.characterId],
|
||||
references: [character.id],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export const characterBookmarksRelations = relations(
|
||||
characterBookmarks,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [characterBookmarks.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
character: one(character, {
|
||||
fields: [characterBookmarks.characterId],
|
||||
references: [character.id],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './accounts'
|
||||
export * from './characters'
|
||||
export * from './chats'
|
||||
export * from './providers'
|
||||
export * from './user-character'
|
||||
|
||||
@@ -6,8 +6,8 @@ import { boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
import { nanoid } from '../utils/id'
|
||||
import { user } from './accounts'
|
||||
|
||||
export const providerConfigs = pgTable(
|
||||
'provider_configs',
|
||||
export const userProviderConfigs = pgTable(
|
||||
'user_provider_configs',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
@@ -16,21 +16,41 @@ export const providerConfigs = pgTable(
|
||||
config: jsonb('config').notNull().default({}),
|
||||
validated: boolean('validated').notNull().default(false),
|
||||
validationBypassed: boolean('validation_bypassed').notNull().default(false),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
},
|
||||
)
|
||||
|
||||
export type ProviderConfig = InferSelectModel<typeof providerConfigs>
|
||||
export type NewProviderConfig = InferInsertModel<typeof providerConfigs>
|
||||
export type UserProviderConfig = InferSelectModel<typeof userProviderConfigs>
|
||||
export type NewUserProviderConfig = InferInsertModel<typeof userProviderConfigs>
|
||||
|
||||
export const providerConfigsRelations = relations(
|
||||
providerConfigs,
|
||||
export const userProviderConfigsRelations = relations(
|
||||
userProviderConfigs,
|
||||
({ one }) => ({
|
||||
owner: one(user, {
|
||||
fields: [providerConfigs.ownerId],
|
||||
fields: [userProviderConfigs.ownerId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export const systemProviderConfigs = pgTable(
|
||||
'system_provider_configs',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
definitionId: text('definition_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
config: jsonb('config').notNull().default({}),
|
||||
validated: boolean('validated').notNull().default(false),
|
||||
validationBypassed: boolean('validation_bypassed').notNull().default(false),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
},
|
||||
)
|
||||
|
||||
export type SystemProviderConfig = InferSelectModel<typeof systemProviderConfigs>
|
||||
export type NewSystemProviderConfig = InferInsertModel<typeof systemProviderConfigs>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
||||
|
||||
import { pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
import { relations } from 'drizzle-orm/relations'
|
||||
|
||||
import { user } from './accounts'
|
||||
import { character } from './characters'
|
||||
|
||||
export const characterLikes = pgTable(
|
||||
'user_character_likes',
|
||||
{
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
primaryKey({ columns: [table.userId, table.characterId] }),
|
||||
],
|
||||
)
|
||||
|
||||
export type CharacterLike = InferSelectModel<typeof characterLikes>
|
||||
export type NewCharacterLike = InferInsertModel<typeof characterLikes>
|
||||
|
||||
export const characterBookmarks = pgTable(
|
||||
'user_character_bookmarks',
|
||||
{
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
primaryKey({ columns: [table.userId, table.characterId] }),
|
||||
],
|
||||
)
|
||||
|
||||
export type CharacterBookmark = InferSelectModel<typeof characterBookmarks>
|
||||
export type NewCharacterBookmark = InferInsertModel<typeof characterBookmarks>
|
||||
|
||||
export const characterLikesRelations = relations(
|
||||
characterLikes,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [characterLikes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
character: one(character, {
|
||||
fields: [characterLikes.characterId],
|
||||
references: [character.id],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export const characterBookmarksRelations = relations(
|
||||
characterBookmarks,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [characterBookmarks.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
character: one(character, {
|
||||
fields: [characterBookmarks.characterId],
|
||||
references: [character.id],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -36,12 +36,14 @@ describe('characterService', () => {
|
||||
const result = await service.create({
|
||||
character: characterData,
|
||||
i18n: [{ language: 'en', name: 'Aster', description: 'desc', tags: [] }],
|
||||
cover: { foregroundUrl: 'fg', backgroundUrl: 'bg' },
|
||||
})
|
||||
|
||||
expect(result.id).toBe('char-1')
|
||||
|
||||
const found = await service.findById('char-1')
|
||||
expect(found?.i18n[0].name).toBe('Aster')
|
||||
expect(found?.cover?.foregroundUrl).toBe('fg')
|
||||
})
|
||||
|
||||
it('findAll should return characters with relations', async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('providerService', () => {
|
||||
testUser = user
|
||||
})
|
||||
|
||||
it('create should handle provider config creation', async () => {
|
||||
it('createUserConfig should handle provider config creation', async () => {
|
||||
const providerData = {
|
||||
id: 'prov-1',
|
||||
ownerId: testUser.id,
|
||||
@@ -34,30 +34,58 @@ describe('providerService', () => {
|
||||
validationBypassed: false,
|
||||
}
|
||||
|
||||
const result = await service.create(providerData)
|
||||
const result = await service.createUserConfig(providerData)
|
||||
expect(result.id).toBe('prov-1')
|
||||
expect(result.name).toBe('My OpenAI')
|
||||
|
||||
const found = await service.findById('prov-1')
|
||||
const found = await service.findUserConfigById('prov-1')
|
||||
expect(found?.definitionId).toBe('openai')
|
||||
expect((found?.config as any).apiKey).toBe('sk-123')
|
||||
})
|
||||
|
||||
it('findByOwnerId should return providers for the user', async () => {
|
||||
const result = await service.findByOwnerId(testUser.id)
|
||||
it('findUserConfigsByOwnerId should return providers for the user', async () => {
|
||||
const result = await service.findUserConfigsByOwnerId(testUser.id)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].ownerId).toBe(testUser.id)
|
||||
})
|
||||
|
||||
it('update should update provider fields', async () => {
|
||||
await service.update('prov-1', { name: 'Updated OpenAI' })
|
||||
const prov = await service.findById('prov-1')
|
||||
it('findAll should return both user and system configs', async () => {
|
||||
// Create a system config
|
||||
await db.insert(schema.systemProviderConfigs).values({
|
||||
id: 'sys-1',
|
||||
definitionId: 'anthropic',
|
||||
name: 'System Anthropic',
|
||||
config: { apiKey: 'sys-sk' },
|
||||
})
|
||||
|
||||
const result = await service.findAll(testUser.id)
|
||||
expect(result.length).toBe(2)
|
||||
|
||||
const userConfig = result.find(r => r.id === 'prov-1')
|
||||
const systemConfig = result.find(r => r.id === 'sys-1')
|
||||
|
||||
expect(userConfig?.isSystem).toBe(false)
|
||||
expect(systemConfig?.isSystem).toBe(true)
|
||||
expect(systemConfig?.name).toBe('System Anthropic')
|
||||
})
|
||||
|
||||
it('findById should find both user and system configs', async () => {
|
||||
const userFound = await service.findById('prov-1', testUser.id)
|
||||
expect(userFound?.isSystem).toBe(false)
|
||||
|
||||
const sysFound = await service.findById('sys-1', testUser.id)
|
||||
expect(sysFound?.isSystem).toBe(true)
|
||||
})
|
||||
|
||||
it('updateUserConfig should update provider fields', async () => {
|
||||
await service.updateUserConfig('prov-1', { name: 'Updated OpenAI' })
|
||||
const prov = await service.findUserConfigById('prov-1')
|
||||
expect(prov?.name).toBe('Updated OpenAI')
|
||||
})
|
||||
|
||||
it('delete should soft delete provider', async () => {
|
||||
await service.delete('prov-1')
|
||||
const prov = await service.findById('prov-1')
|
||||
it('deleteUserConfig should soft delete provider', async () => {
|
||||
await service.deleteUserConfig('prov-1')
|
||||
const prov = await service.findUserConfigById('prov-1')
|
||||
expect(prov).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Database } from './db'
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/characters'
|
||||
import * as userCharacterSchema from '../schemas/user-character'
|
||||
|
||||
export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
return {
|
||||
@@ -21,6 +22,7 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
prompts: true,
|
||||
likes: true,
|
||||
bookmarks: true,
|
||||
cover: true,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
@@ -37,6 +39,7 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
capabilities: true,
|
||||
likes: true,
|
||||
bookmarks: true,
|
||||
cover: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -50,6 +53,7 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
capabilities: true,
|
||||
likes: true,
|
||||
bookmarks: true,
|
||||
cover: true,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
@@ -59,16 +63,16 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
return await db.transaction(async (tx) => {
|
||||
const existing = await tx.query.characterLikes.findFirst({
|
||||
where: and(
|
||||
eq(schema.characterLikes.userId, userId),
|
||||
eq(schema.characterLikes.characterId, characterId),
|
||||
eq(userCharacterSchema.characterLikes.userId, userId),
|
||||
eq(userCharacterSchema.characterLikes.characterId, characterId),
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx.delete(schema.characterLikes)
|
||||
await tx.delete(userCharacterSchema.characterLikes)
|
||||
.where(and(
|
||||
eq(schema.characterLikes.userId, userId),
|
||||
eq(schema.characterLikes.characterId, characterId),
|
||||
eq(userCharacterSchema.characterLikes.userId, userId),
|
||||
eq(userCharacterSchema.characterLikes.characterId, characterId),
|
||||
))
|
||||
|
||||
await tx.update(schema.character)
|
||||
@@ -80,7 +84,7 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
return { liked: false }
|
||||
}
|
||||
else {
|
||||
await tx.insert(schema.characterLikes).values({ userId, characterId })
|
||||
await tx.insert(userCharacterSchema.characterLikes).values({ userId, characterId })
|
||||
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
@@ -97,16 +101,16 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
return await db.transaction(async (tx) => {
|
||||
const existing = await tx.query.characterBookmarks.findFirst({
|
||||
where: and(
|
||||
eq(schema.characterBookmarks.userId, userId),
|
||||
eq(schema.characterBookmarks.characterId, characterId),
|
||||
eq(userCharacterSchema.characterBookmarks.userId, userId),
|
||||
eq(userCharacterSchema.characterBookmarks.characterId, characterId),
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await tx.delete(schema.characterBookmarks)
|
||||
await tx.delete(userCharacterSchema.characterBookmarks)
|
||||
.where(and(
|
||||
eq(schema.characterBookmarks.userId, userId),
|
||||
eq(schema.characterBookmarks.characterId, characterId),
|
||||
eq(userCharacterSchema.characterBookmarks.userId, userId),
|
||||
eq(userCharacterSchema.characterBookmarks.characterId, characterId),
|
||||
))
|
||||
|
||||
await tx.update(schema.character)
|
||||
@@ -118,7 +122,7 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
return { bookmarked: false }
|
||||
}
|
||||
else {
|
||||
await tx.insert(schema.characterBookmarks).values({ userId, characterId })
|
||||
await tx.insert(userCharacterSchema.characterBookmarks).values({ userId, characterId })
|
||||
|
||||
await tx.update(schema.character)
|
||||
.set({
|
||||
@@ -133,6 +137,7 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
|
||||
async create(data: {
|
||||
character: schema.NewCharacter
|
||||
cover?: Omit<schema.NewCharacterCover, 'characterId'>
|
||||
capabilities?: Omit<schema.NewCharacterCapability, 'characterId'>[]
|
||||
avatarModels?: Omit<schema.NewAvatarModel, 'characterId'>[]
|
||||
i18n?: Omit<schema.NewCharacterI18n, 'characterId'>[]
|
||||
@@ -141,6 +146,13 @@ export function createCharacterService(db: Database<typeof fullSchema>) {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [inserted] = await tx.insert(schema.character).values(data.character).returning()
|
||||
|
||||
if (data.cover) {
|
||||
await tx.insert(schema.characterCovers).values({
|
||||
...data.cover,
|
||||
characterId: inserted.id,
|
||||
} as schema.NewCharacterCover)
|
||||
}
|
||||
|
||||
if (data.capabilities?.length) {
|
||||
await tx.insert(schema.characterCapabilities).values(
|
||||
data.capabilities.map(c => ({ ...c, characterId: inserted.id }) as schema.NewCharacterCapability),
|
||||
|
||||
@@ -1,52 +1,160 @@
|
||||
import type * as fullSchema from '../schemas'
|
||||
import type { Database } from './db'
|
||||
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/providers'
|
||||
|
||||
export function createProviderService(db: Database<typeof fullSchema>) {
|
||||
return {
|
||||
async findByOwnerId(ownerId: string) {
|
||||
return await db.query.providerConfigs.findMany({
|
||||
async findAll(ownerId: string) {
|
||||
const userConfigs = db
|
||||
.select({
|
||||
id: schema.userProviderConfigs.id,
|
||||
definitionId: schema.userProviderConfigs.definitionId,
|
||||
name: schema.userProviderConfigs.name,
|
||||
config: schema.userProviderConfigs.config,
|
||||
validated: schema.userProviderConfigs.validated,
|
||||
validationBypassed: schema.userProviderConfigs.validationBypassed,
|
||||
createdAt: schema.userProviderConfigs.createdAt,
|
||||
updatedAt: schema.userProviderConfigs.updatedAt,
|
||||
isSystem: sql<boolean>`false`.as('is_system'),
|
||||
})
|
||||
.from(schema.userProviderConfigs)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userProviderConfigs.ownerId, ownerId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
)
|
||||
|
||||
const systemConfigs = db
|
||||
.select({
|
||||
id: schema.systemProviderConfigs.id,
|
||||
definitionId: schema.systemProviderConfigs.definitionId,
|
||||
name: schema.systemProviderConfigs.name,
|
||||
config: schema.systemProviderConfigs.config,
|
||||
validated: schema.systemProviderConfigs.validated,
|
||||
validationBypassed: schema.systemProviderConfigs.validationBypassed,
|
||||
createdAt: schema.systemProviderConfigs.createdAt,
|
||||
updatedAt: schema.systemProviderConfigs.updatedAt,
|
||||
isSystem: sql<boolean>`true`.as('is_system'),
|
||||
})
|
||||
.from(schema.systemProviderConfigs)
|
||||
.where(isNull(schema.systemProviderConfigs.deletedAt))
|
||||
|
||||
return await userConfigs.unionAll(systemConfigs)
|
||||
},
|
||||
|
||||
async findUserConfigsByOwnerId(ownerId: string) {
|
||||
return await db.query.userProviderConfigs.findMany({
|
||||
where: and(
|
||||
eq(schema.providerConfigs.ownerId, ownerId),
|
||||
isNull(schema.providerConfigs.deletedAt),
|
||||
eq(schema.userProviderConfigs.ownerId, ownerId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
return await db.query.providerConfigs.findFirst({
|
||||
async findById(id: string, ownerId: string) {
|
||||
const userConfig = await db.query.userProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.providerConfigs.id, id),
|
||||
isNull(schema.providerConfigs.deletedAt),
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
eq(schema.userProviderConfigs.ownerId, ownerId),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (userConfig) {
|
||||
return { ...userConfig, isSystem: false }
|
||||
}
|
||||
|
||||
const systemConfig = await db.query.systemProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
|
||||
if (systemConfig) {
|
||||
return { ...systemConfig, isSystem: true }
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
async findUserConfigById(id: string) {
|
||||
return await db.query.userProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async create(data: schema.NewProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.providerConfigs).values(data).returning()
|
||||
async createUserConfig(data: schema.NewUserProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.userProviderConfigs).values(data).returning()
|
||||
return inserted
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<schema.NewProviderConfig>) {
|
||||
const [updated] = await db.update(schema.providerConfigs)
|
||||
async updateUserConfig(id: string, data: Partial<schema.NewUserProviderConfig>) {
|
||||
const [updated] = await db.update(schema.userProviderConfigs)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.providerConfigs.id, id),
|
||||
isNull(schema.providerConfigs.deletedAt),
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
return updated
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return await db.update(schema.providerConfigs)
|
||||
async deleteUserConfig(id: string) {
|
||||
return await db.update(schema.userProviderConfigs)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.providerConfigs.id, id),
|
||||
isNull(schema.providerConfigs.deletedAt),
|
||||
eq(schema.userProviderConfigs.id, id),
|
||||
isNull(schema.userProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
},
|
||||
|
||||
// System Provider Configs
|
||||
async findSystemConfigs() {
|
||||
return await db.query.systemProviderConfigs.findMany({
|
||||
where: isNull(schema.systemProviderConfigs.deletedAt),
|
||||
})
|
||||
},
|
||||
|
||||
async findSystemConfigById(id: string) {
|
||||
return await db.query.systemProviderConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
async createSystemConfig(data: schema.NewSystemProviderConfig) {
|
||||
const [inserted] = await db.insert(schema.systemProviderConfigs).values(data).returning()
|
||||
return inserted
|
||||
},
|
||||
|
||||
async updateSystemConfig(id: string, data: Partial<schema.NewSystemProviderConfig>) {
|
||||
const [updated] = await db.update(schema.systemProviderConfigs)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
return updated
|
||||
},
|
||||
|
||||
async deleteSystemConfig(id: string) {
|
||||
return await db.update(schema.systemProviderConfigs)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.systemProviderConfigs.id, id),
|
||||
isNull(schema.systemProviderConfigs.deletedAt),
|
||||
))
|
||||
.returning()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user