feat: add group recipe sharing and translation

This commit is contained in:
2026-08-14 16:46:01 +09:00
parent 146e78cddf
commit 4491c91455
13 changed files with 170 additions and 19 deletions
+2
View File
@@ -55,10 +55,12 @@ export async function buildApp({ config = loadConfig(), dependencies = {} } = {}
if (
dependencies.allowedEmailRepository
&& dependencies.groupRepository
&& dependencies.recipeRepository
&& dependencies.userRepository
) {
app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository);
app.decorate('groupRepository', dependencies.groupRepository);
app.decorate('recipeRepository', dependencies.recipeRepository);
app.decorate('userRepository', dependencies.userRepository);
} else {
+1
View File
@@ -37,6 +37,7 @@ export function mongoCollectionNames(nodeEnv) {
const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : '';
return Object.freeze({
allowedEmails: 'allowed_google_emails',
groups: `groups${suffix}`,
recipes: `recipes${suffix}`,
users: `users${suffix}`,
});
+4
View File
@@ -1,6 +1,7 @@
import { MongoClient } from 'mongodb';
import fastifyPlugin from 'fastify-plugin';
import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js';
import { GroupRepository } from '../repositories/group.repository.js';
import { RecipeRepository } from '../repositories/recipe.repository.js';
import { UserRepository } from '../repositories/user.repository.js';
@@ -49,11 +50,13 @@ async function mongoPlugin(fastify, { config }) {
const db = client.db();
const collections = config.mongoCollections;
const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails);
const groupRepository = new GroupRepository(db, collections.groups);
const recipeRepository = new RecipeRepository(db, collections.recipes);
const userRepository = new UserRepository(db, collections.users);
await Promise.all([
allowedEmailRepository.ensureIndexes(),
groupRepository.ensureIndexes(),
recipeRepository.ensureIndexes(),
userRepository.ensureIndexes(),
]);
@@ -65,6 +68,7 @@ async function mongoPlugin(fastify, { config }) {
fastify.decorate('mongoClient', client);
fastify.decorate('db', db);
fastify.decorate('allowedEmailRepository', allowedEmailRepository);
fastify.decorate('groupRepository', groupRepository);
fastify.decorate('recipeRepository', recipeRepository);
fastify.decorate('userRepository', userRepository);
+24
View File
@@ -0,0 +1,24 @@
export class GroupRepository {
constructor(db, collectionName = 'groups') {
this.collection = db.collection(collectionName);
}
async ensureIndexes() {
await this.collection.createIndex({ memberGoogleSubs: 1 });
}
async listMemberGoogleSubs(googleSub) {
const groups = await this.collection
.find(
{ memberGoogleSubs: googleSub },
{ projection: { _id: 0, memberGoogleSubs: 1 } },
)
.toArray();
return [...new Set(
groups
.flatMap((group) => group.memberGoogleSubs ?? [])
.filter((memberGoogleSub) => typeof memberGoogleSub === 'string'),
)];
}
}
+12 -1
View File
@@ -16,9 +16,13 @@ export class RecipeRepository {
}
async listByOwner(ownerGoogleSub) {
return this.listByOwners([ownerGoogleSub]);
}
async listByOwners(ownerGoogleSubs) {
return this.collection
.find(
{ ownerGoogleSub },
{ ownerGoogleSub: { $in: ownerGoogleSubs } },
{
projection: {
'source.rawText': 0,
@@ -34,6 +38,13 @@ export class RecipeRepository {
return this.collection.findOne({ _id: recipeId, ownerGoogleSub });
}
async findByIdForOwners(ownerGoogleSubs, recipeId) {
return this.collection.findOne({
_id: recipeId,
ownerGoogleSub: { $in: ownerGoogleSubs },
});
}
async findBySource(ownerGoogleSub, platform, sourceId) {
return this.collection.findOne({
ownerGoogleSub,
+14 -4
View File
@@ -29,13 +29,23 @@ function verifiedSource(source) {
return source;
}
async function visibleOwnerGoogleSubs(fastify, googleSub) {
const groupMembers = await fastify.groupRepository.listMemberGoogleSubs(googleSub);
return [...new Set([googleSub, ...groupMembers])];
}
export default async function recipeRoutes(fastify) {
fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => ({
recipes: await fastify.recipeRepository.listByOwner(request.user.sub),
}));
fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => {
const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub);
return { recipes: await fastify.recipeRepository.listByOwners(ownerGoogleSubs) };
});
fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => {
const recipe = await fastify.recipeRepository.findById(request.user.sub, request.params.id);
const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub);
const recipe = await fastify.recipeRepository.findByIdForOwners(
ownerGoogleSubs,
request.params.id,
);
if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.');
return { recipe };
});
+3
View File
@@ -13,6 +13,9 @@ const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데
- 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다.
- g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다.
- 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다.
- 원문이 주로 영어 또는 다른 외국어이면 title, summary, servings, ingredientGroups, steps, tips를 자연스러운 한국어로 번역합니다.
- 번역할 때 재료와 요리의 고유명사는 의미가 달라지지 않게 유지하고, 수량, 온도, 시간 값은 변환하지 않습니다.
- 원문이 이미 한국어이면 불필요하게 문체나 표현을 바꾸지 않습니다.
- YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다.
- Instagram 단계의 timestampSec은 null입니다.
- 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다.