Add Telegram ownership for voice reviews
All checks were successful
Build and deploy Backend / build (push) Successful in 1m1s

This commit is contained in:
Ruslan Bakiev
2026-05-08 16:44:32 +07:00
parent bba9c98c82
commit f956148141
14 changed files with 2107 additions and 111 deletions

View File

@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "VoiceExperience" ADD COLUMN "userId" TEXT;
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"telegramId" TEXT NOT NULL,
"username" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"photoUrl" TEXT,
"languageCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId");
-- AddForeignKey
ALTER TABLE "VoiceExperience" ADD CONSTRAINT "VoiceExperience_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -27,10 +27,25 @@ model Place {
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VoiceExperience {
id String @id @default(cuid())
placeId String
place Place @relation(fields: [placeId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
durationSeconds Int
audioObjectKey String
status VoiceExperienceStatus @default(UPLOADED)

98
src/auth/telegram.ts Normal file
View File

@@ -0,0 +1,98 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
import { prisma } from '../prisma.js';
type TelegramInitDataUser = {
id: number;
username?: string;
first_name?: string;
last_name?: string;
photo_url?: string;
language_code?: string;
};
function hmacSha256(key: string | Buffer, value: string) {
return createHmac('sha256', key).update(value).digest();
}
function assertValidHash(receivedHash: string, expectedHash: Buffer) {
const received = Buffer.from(receivedHash, 'hex');
if (received.length !== expectedHash.length) {
throw new Error('Telegram init data hash is invalid.');
}
if (!timingSafeEqual(received, expectedHash)) {
throw new Error('Telegram init data hash is invalid.');
}
}
function parseTelegramInitData(initData: string) {
if (!config.telegramMiniAppBotToken) {
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
}
const params = new URLSearchParams(initData);
const receivedHash = params.get('hash');
if (!receivedHash) {
throw new Error('Telegram init data hash is required.');
}
const authDate = Number(params.get('auth_date'));
if (!Number.isFinite(authDate)) {
throw new Error('Telegram auth_date is required.');
}
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
throw new Error('Telegram init data is expired.');
}
const dataCheckString = [...params.entries()]
.filter(([key]) => key !== 'hash')
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken);
const expectedHash = hmacSha256(secretKey, dataCheckString);
assertValidHash(receivedHash, expectedHash);
const rawUser = params.get('user');
if (!rawUser) {
throw new Error('Telegram user is required.');
}
return JSON.parse(rawUser) as TelegramInitDataUser;
}
export async function getOrCreateTelegramUser(initData: string) {
const user = parseTelegramInitData(initData);
return prisma.user.upsert({
where: { telegramId: String(user.id) },
create: {
telegramId: String(user.id),
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
photoUrl: user.photo_url,
languageCode: user.language_code,
},
update: {
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
photoUrl: user.photo_url,
languageCode: user.language_code,
},
});
}
export async function requireTelegramUser(initData?: string) {
if (!initData) {
throw new Error('Telegram authorization is required.');
}
return getOrCreateTelegramUser(initData);
}

View File

@@ -5,4 +5,8 @@ export const config = {
port: Number(process.env.PORT ?? '4000'),
databaseUrl: process.env.DATABASE_URL ?? '',
hatchetToken: process.env.HATCHET_CLIENT_TOKEN ?? '',
telegramMiniAppBotToken: process.env.TELEGRAM_MINI_APP_BOT_TOKEN ?? '',
telegramAuthMaxAgeSeconds: Number(
process.env.TELEGRAM_AUTH_MAX_AGE_SECONDS ?? '86400',
),
};

File diff suppressed because one or more lines are too long

View File

@@ -130,9 +130,22 @@ exports.Prisma.PlaceScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
telegramId: 'telegramId',
username: 'username',
firstName: 'firstName',
lastName: 'lastName',
photoUrl: 'photoUrl',
languageCode: 'languageCode',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.VoiceExperienceScalarFieldEnum = {
id: 'id',
placeId: 'placeId',
userId: 'userId',
durationSeconds: 'durationSeconds',
audioObjectKey: 'audioObjectKey',
status: 'status',
@@ -157,16 +170,16 @@ exports.Prisma.QueryMode = {
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.Prisma.JsonNullValueFilter = {
DbNull: Prisma.DbNull,
JsonNull: Prisma.JsonNull,
AnyNull: Prisma.AnyNull
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
UPLOADED: 'UPLOADED',
TRANSCRIBING: 'TRANSCRIBING',
@@ -178,6 +191,7 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
exports.Prisma.ModelName = {
Place: 'Place',
User: 'User',
VoiceExperience: 'VoiceExperience'
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-5316d8e66293a4954be6e26169692366997bf25fe9186e98c87e62eed3dc691e",
"name": "prisma-client-359590a92aedb32557346cd24ba7dd77e7dd4a806bf36f6641715b1b22de87e3",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View File

@@ -27,10 +27,25 @@ model Place {
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VoiceExperience {
id String @id @default(cuid())
placeId String
place Place @relation(fields: [placeId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
durationSeconds Int
audioObjectKey String
status VoiceExperienceStatus @default(UPLOADED)

View File

@@ -15,6 +15,7 @@ export async function listPlaces() {
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
orderBy: { updatedAt: 'desc' },
@@ -28,7 +29,7 @@ export async function listPlaces() {
export async function listVoiceExperiences() {
const experiences = await prisma.voiceExperience.findMany({
include: { place: true },
include: { place: true, user: true },
orderBy: { createdAt: 'desc' },
take: 100,
});

View File

@@ -1,7 +1,12 @@
import { GraphQLJSONObject } from './scalars.js';
import { getOrCreateTelegramUser, requireTelegramUser } from '../auth/telegram.js';
import { listPlaces, listVoiceExperiences } from './places.js';
import { createVoiceExperience } from './voice-experiences.js';
export type GraphqlContext = {
telegramInitData?: string;
};
export const schema = /* GraphQL */ `
scalar JSON
@@ -23,9 +28,20 @@ export const schema = /* GraphQL */ `
experiences: [VoiceExperience!]!
}
type User {
id: ID!
telegramId: String!
username: String
firstName: String
lastName: String
photoUrl: String
languageCode: String
}
type VoiceExperience {
id: ID!
place: Place!
user: User
status: VoiceExperienceStatus!
durationSeconds: Int!
audioObjectKey: String!
@@ -43,6 +59,14 @@ export const schema = /* GraphQL */ `
audioObjectKey: String!
}
input AuthenticateTelegramInput {
initData: String!
}
type AuthPayload {
user: User!
}
type Query {
health: String!
places: [Place!]!
@@ -50,6 +74,7 @@ export const schema = /* GraphQL */ `
}
type Mutation {
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
}
`;
@@ -62,9 +87,18 @@ export const resolvers = {
voiceExperiences: () => listVoiceExperiences(),
},
Mutation: {
authenticateTelegram: async (
_: unknown,
args: { input: { initData: string } },
) => ({ user: await getOrCreateTelegramUser(args.input.initData) }),
createVoiceExperience: async (
_: unknown,
args: { input: Parameters<typeof createVoiceExperience>[0] },
) => createVoiceExperience(args.input),
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext.telegramInitData);
return createVoiceExperience(args.input, user.id);
},
},
};

View File

@@ -12,7 +12,10 @@ export type CreateVoiceExperienceInput = {
audioObjectKey: string;
};
export async function createVoiceExperience(input: CreateVoiceExperienceInput) {
export async function createVoiceExperience(
input: CreateVoiceExperienceInput,
userId: string,
) {
if (input.durationSeconds < minimumVoiceExperienceSeconds) {
throw new Error('Voice experience must be at least 30 seconds.');
}
@@ -35,11 +38,12 @@ export async function createVoiceExperience(input: CreateVoiceExperienceInput) {
const experience = await prisma.voiceExperience.create({
data: {
placeId: place.id,
userId,
durationSeconds: input.durationSeconds,
audioObjectKey: input.audioObjectKey,
status: 'UPLOADED',
},
include: { place: true },
include: { place: true, user: true },
});
await enqueueVoiceExperience({ experienceId: experience.id });

View File

@@ -11,6 +11,12 @@ app.register(mercurius, {
schema,
resolvers,
graphiql: true,
context: async (request) => {
const header = request.headers['x-telegram-init-data'];
return {
telegramInitData: Array.isArray(header) ? header[0] : header,
};
},
});
app.get('/health', async () => ({ ok: true }));