dptxa-proxy / src /lib /txasupabase.js
TXAVLOG
Deploy DPTXA to Hugging Face Spaces
4bea261
Raw
History Blame Contribute Delete
35.2 kB
import { createClient } from '@supabase/supabase-js';
import crypto from 'node:crypto';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || process.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY || process.env.PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.warn("Supabase credentials missing! Please configure PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY in your .env file.");
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Kiểm tra kết nối Supabase và in log ra Terminal CLI
if (supabaseUrl && supabaseAnonKey) {
supabase.from('settings').select('key').limit(1)
.then(({ error }) => {
if (error) {
console.log("\x1b[41m\x1b[37m[Supabase]\x1b[0m \x1b[31m❌ KẾT NỐI THẤT BẠI: " + error.message + "\x1b[0m");
} else {
console.log("\x1b[42m\x1b[30m[Supabase]\x1b[0m \x1b[32m✅ KẾT NỐI THÀNH CÔNG tới " + supabaseUrl + "\x1b[0m");
}
})
.catch(err => {
console.log("\x1b[41m\x1b[37m[Supabase]\x1b[0m \x1b[31m❌ LỖI KẾT NỐI: " + err.message + "\x1b[0m");
});
}
export class txasupabase {
static get client() { return supabase; }
static get supabase() { return supabase; }
// --- MOVIES ---
static async getLocalMovies() {
const { data, error } = await supabase
.from('movies')
.select('slug, movie, episodes');
if (error) {
console.error('Error fetching movies from Supabase:', error);
return {};
}
const moviesObj = {};
(data || []).forEach(row => {
moviesObj[row.slug] = {
movie: row.movie,
episodes: row.episodes
};
});
return moviesObj;
}
static async saveLocalMovie(slug, movieData) {
const existing = await this.getMovieBySlug(slug);
const updatedMovie = {
...existing?.movie,
...movieData.movie,
slug,
modified: { time: new Date().toISOString() }
};
const updatedEpisodes = movieData.episodes || existing?.episodes || [];
// So sánh xem có tập mới không!
let newEpisodesAdded = [];
if (existing && existing.episodes && movieData.episodes) {
for (const newServer of movieData.episodes) {
const extServer = existing.episodes.find(s => s.server_name === newServer.server_name);
if (!extServer) {
newEpisodesAdded.push(...(newServer.server_data || []));
} else {
for (const newEp of (newServer.server_data || [])) {
const hasEp = extServer.server_data.some(e => e.name === newEp.name);
if (!hasEp) {
newEpisodesAdded.push(newEp);
}
}
}
}
}
const { data, error } = await supabase
.from('movies')
.upsert({
slug,
movie: updatedMovie,
episodes: updatedEpisodes,
updated_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error(`Error saving movie ${slug} to Supabase:`, error);
throw error;
}
if (newEpisodesAdded.length > 0) {
this.notifyNewEpisodes(slug, updatedMovie.name, newEpisodesAdded).catch(err => {
console.error('[Notification Trigger Error]:', err);
});
}
return {
movie: data.movie,
episodes: data.episodes
};
}
static async getMovieBySlug(slug) {
const { data, error } = await supabase
.from('movies')
.select('slug, movie, episodes')
.eq('slug', slug)
.maybeSingle();
if (error) {
console.error(`Error fetching movie ${slug} from Supabase:`, error);
return null;
}
return data ? { movie: data.movie, episodes: data.episodes } : null;
}
static async resetLocalMovie(slug) {
const { error } = await supabase
.from('movies')
.delete()
.eq('slug', slug);
if (error) {
console.error(`Error resetting movie ${slug} on Supabase:`, error);
return false;
}
return true;
}
// --- DELETED MOVIES ---
static async getDeletedMovies() {
const { data, error } = await supabase
.from('deleted_movies')
.select('slug');
if (error) {
console.error('Error fetching deleted movies from Supabase:', error);
return [];
}
return (data || []).map(row => row.slug);
}
static async deleteLocalMovie(slug) {
// 1. Nếu là phim cục bộ tự tạo, xóa khỏi movies
const movieObj = await this.getMovieBySlug(slug);
let isLocalOnly = false;
if (movieObj && movieObj.movie.isLocal) {
const { error } = await supabase
.from('movies')
.delete()
.eq('slug', slug);
if (error) console.error('Error deleting local movie from Supabase:', error);
isLocalOnly = true;
}
// 2. Thêm vào deleted_movies để ẩn khỏi API
const { error: delError } = await supabase
.from('deleted_movies')
.upsert({ slug })
.select();
if (delError) {
console.error('Error adding movie to deleted_movies on Supabase:', delError);
}
return { success: true, isLocalOnly };
}
static async restoreLocalMovie(slug) {
const { error } = await supabase
.from('deleted_movies')
.delete()
.eq('slug', slug);
if (error) {
console.error(`Error restoring movie ${slug} from Supabase:`, error);
return false;
}
return true;
}
// --- GENRES ---
static async getGenres() {
const { data, error } = await supabase
.from('genres')
.select('slug, name')
.order('name');
if (error) {
console.error('Error fetching genres from Supabase:', error);
return [];
}
return data || [];
}
static async saveGenre(slug, name) {
const { error } = await supabase
.from('genres')
.upsert({ slug, name });
if (error) {
console.error(`Error saving genre ${slug} to Supabase:`, error);
}
return await this.getGenres();
}
static async deleteGenre(slug) {
const { error } = await supabase
.from('genres')
.delete()
.eq('slug', slug);
if (error) {
console.error(`Error deleting genre ${slug} from Supabase:`, error);
}
return await this.getGenres();
}
static async bulkDeleteGenres(slugs) {
const { error } = await supabase
.from('genres')
.delete()
.in('slug', slugs);
if (error) {
console.error('Error bulk deleting genres from Supabase:', error);
}
return await this.getGenres();
}
// --- COUNTRIES ---
static async getCountries() {
const { data, error } = await supabase
.from('countries')
.select('slug, name')
.order('name');
if (error) {
console.error('Error fetching countries from Supabase:', error);
return [];
}
return data || [];
}
static async saveCountry(slug, name) {
const { error } = await supabase
.from('countries')
.upsert({ slug, name });
if (error) {
console.error(`Error saving country ${slug} to Supabase:`, error);
}
return await this.getCountries();
}
static async deleteCountry(slug) {
const { error } = await supabase
.from('countries')
.delete()
.eq('slug', slug);
if (error) {
console.error(`Error deleting country ${slug} from Supabase:`, error);
}
return await this.getCountries();
}
static async bulkDeleteCountries(slugs) {
const { error } = await supabase
.from('countries')
.delete()
.in('slug', slugs);
if (error) {
console.error('Error bulk deleting countries from Supabase:', error);
}
return await this.getCountries();
}
// --- ACTORS ---
static async getActors() {
const { data, error } = await supabase
.from('actors')
.select('name')
.order('name');
if (error) {
console.error('Error fetching actors from Supabase:', error);
return [];
}
return data || [];
}
static async saveActor(name, originalName = null) {
const cleanName = name.trim();
if (originalName && originalName.trim().toLowerCase() !== cleanName.toLowerCase()) {
const { error: delError } = await supabase
.from('actors')
.delete()
.eq('name', originalName.trim());
if (delError) console.error('Error deleting renamed actor on Supabase:', delError);
}
const { error } = await supabase
.from('actors')
.upsert({ name: cleanName });
if (error) {
console.error(`Error saving actor ${cleanName} to Supabase:`, error);
}
return await this.getActors();
}
static async deleteActor(name) {
const { error } = await supabase
.from('actors')
.delete()
.eq('name', name);
if (error) {
console.error(`Error deleting actor ${name} from Supabase:`, error);
}
return await this.getActors();
}
static async bulkDeleteActors(names) {
const { error } = await supabase
.from('actors')
.delete()
.in('name', names);
if (error) {
console.error('Error bulk deleting actors from Supabase:', error);
}
return await this.getActors();
}
// --- SERVERS ---
static async getServers() {
const { data, error } = await supabase
.from('servers')
.select('name')
.order('name');
if (error) {
console.error('Error fetching servers from Supabase:', error);
return [];
}
return data || [];
}
static async saveServer(name, originalName = null) {
const cleanName = name.trim();
if (originalName && originalName.trim().toLowerCase() !== cleanName.toLowerCase()) {
const { error: delError } = await supabase
.from('servers')
.delete()
.eq('name', originalName.trim());
if (delError) console.error('Error deleting renamed server on Supabase:', delError);
}
const { error } = await supabase
.from('servers')
.upsert({ name: cleanName });
if (error) {
console.error(`Error saving server ${cleanName} to Supabase:`, error);
}
return await this.getServers();
}
static async deleteServer(name) {
const { error } = await supabase
.from('servers')
.delete()
.eq('name', name);
if (error) {
console.error(`Error deleting server ${name} from Supabase:`, error);
}
return await this.getServers();
}
static async bulkDeleteServers(names) {
const { error } = await supabase
.from('servers')
.delete()
.in('name', names);
if (error) {
console.error('Error bulk deleting servers from Supabase:', error);
}
return await this.getServers();
}
// --- AUTOMATIC SYNC FROM MOVIES DATA ---
static async syncFromMovies(type) {
const moviesObj = await this.getLocalMovies();
const allMovies = Object.values(moviesObj);
if (type === 'servers') {
const serversList = await this.getServers();
const serverSet = new Set(serversList.map(s => s.name.toLowerCase()));
const extracted = new Set();
for (const item of allMovies) {
if (item.episodes) {
for (const epGroup of item.episodes) {
if (epGroup.server_name) {
extracted.add(epGroup.server_name.trim());
}
}
}
}
const newServers = [];
for (const sName of extracted) {
if (!serverSet.has(sName.toLowerCase())) {
newServers.push({ name: sName });
serverSet.add(sName.toLowerCase());
}
}
if (newServers.length > 0) {
const { error } = await supabase.from('servers').insert(newServers);
if (error) console.error('Error syncing servers to Supabase:', error);
}
return await this.getServers();
}
if (type === 'genres') {
const genresList = await this.getGenres();
const genreMap = new Map(genresList.map(g => [g.slug, g.name]));
const newGenres = [];
for (const item of allMovies) {
const cats = item.movie.category || [];
for (const cat of cats) {
if (cat.slug && cat.name) {
if (!genreMap.has(cat.slug)) {
newGenres.push({ slug: cat.slug, name: cat.name });
genreMap.set(cat.slug, cat.name);
}
}
}
}
if (newGenres.length > 0) {
const { error } = await supabase.from('genres').insert(newGenres);
if (error) console.error('Error syncing genres to Supabase:', error);
}
return await this.getGenres();
}
if (type === 'countries') {
const countriesList = await this.getCountries();
const countryMap = new Map(countriesList.map(c => [c.slug, c.name]));
const newCountries = [];
for (const item of allMovies) {
const countries = item.movie.country || [];
for (const c of countries) {
if (c.slug && c.name) {
if (!countryMap.has(c.slug)) {
newCountries.push({ slug: c.slug, name: c.name });
countryMap.set(c.slug, c.name);
}
}
}
}
if (newCountries.length > 0) {
const { error } = await supabase.from('countries').insert(newCountries);
if (error) console.error('Error syncing countries to Supabase:', error);
}
return await this.getCountries();
}
if (type === 'actors') {
const actorsList = await this.getActors();
const actorSet = new Set(actorsList.map(a => a.name.toLowerCase()));
const extracted = new Set();
for (const item of allMovies) {
const actors = item.movie.actor || [];
for (const actor of actors) {
if (actor && typeof actor === 'string') {
extracted.add(actor.trim());
}
}
}
const newActors = [];
for (const actorName of extracted) {
if (!actorSet.has(actorName.toLowerCase())) {
newActors.push({ name: actorName });
actorSet.add(actorName.toLowerCase());
}
}
if (newActors.length > 0) {
const { error } = await supabase.from('actors').insert(newActors);
if (error) console.error('Error syncing actors to Supabase:', error);
}
return await this.getActors();
}
return [];
}
// --- USERS ---
static async getUsers() {
const { data, error } = await supabase
.from('users')
.select('*');
if (error) {
console.error('Error fetching users from Supabase:', error);
return [];
}
return data || [];
}
static async getUserByUsername(username) {
const { data, error } = await supabase
.from('users')
.select('*')
.eq('username', username)
.maybeSingle();
if (error) {
console.error(`Error fetching user ${username} from Supabase:`, error);
return null;
}
return data;
}
static async getUserById(id) {
const { data, error } = await supabase
.from('users')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) {
console.error(`Error fetching user by ID ${id} from Supabase:`, error);
return null;
}
return data;
}
static getGravatarUrl(email) {
if (!email) return 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp';
const hash = crypto.createHash('md5').update(email.trim().toLowerCase()).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?d=mp`;
}
static async saveUser(user) {
const defaultAvatar = user.email ? this.getGravatarUrl(user.email) : 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp';
const { data, error } = await supabase
.from('users')
.upsert({
username: user.username,
id: user.id,
email: user.email,
password: user.password,
province: user.province || '',
ward: user.ward || '',
role: user.role || 'user',
favorites: user.favorites || [],
history: user.history || [],
avatar_url: user.avatar_url || defaultAvatar,
created_at: user.created_at || user.createdAt || new Date().toISOString()
})
.select()
.single();
if (error) {
console.error(`Error saving user ${user.username} to Supabase:`, error);
throw error;
}
return data;
}
// --- RATINGS ---
static async getRatingBySlug(slug) {
const { data, error } = await supabase
.from('ratings')
.select('*')
.eq('slug', slug)
.maybeSingle();
if (error) {
console.error(`Error fetching rating for ${slug}:`, error);
return null;
}
return data;
}
static async saveRating(slug, count, totalScore, userRatings, ipRatings = {}) {
const { data, error } = await supabase
.from('ratings')
.upsert({
slug,
count,
total_score: totalScore,
user_ratings: userRatings,
ip_ratings: ipRatings,
updated_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error(`Error saving rating for ${slug}:`, error);
throw error;
}
return data;
}
// --- MOVIE VIEWS & SUGGESTIONS ---
static async incrementViews(slug, ip = '127.0.0.1') {
try {
const { data: existing, error: fetchError } = await supabase
.from('movie_views')
.select('views, ip_log')
.eq('slug', slug)
.maybeSingle();
if (fetchError) {
console.error(`Error fetching views for ${slug}:`, fetchError);
return null;
}
let views = existing?.views || 0;
let ipLog = existing?.ip_log || {};
const now = new Date();
const lastViewTimeStr = ipLog[ip];
if (lastViewTimeStr) {
const lastViewTime = new Date(lastViewTimeStr);
const diffMs = now.getTime() - lastViewTime.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
// Nếu IP này đã xem phim này trong vòng 24 giờ qua, không tính thêm lượt xem
if (diffHours < 24) {
return existing;
}
}
// Tăng lượt xem lên 1
views += 1;
// Ghi nhận thời gian xem của IP này
ipLog[ip] = now.toISOString();
// Dọn dẹp ipLog: chỉ giữ lại các địa chỉ IP đã xem trong vòng 24 giờ qua để tiết kiệm dung lượng JSON
const cleanIpLog = {};
const limitMs = 24 * 60 * 60 * 1000;
Object.entries(ipLog).forEach(([key, val]) => {
if (typeof val === 'string') {
const t = new Date(val).getTime();
if (now.getTime() - t < limitMs) {
cleanIpLog[key] = val;
}
}
});
const { data, error } = await supabase
.from('movie_views')
.upsert({
slug,
views,
ip_log: cleanIpLog,
updated_at: now.toISOString()
})
.select()
.single();
if (error) {
console.error(`Error incrementing views for ${slug}:`, error);
return null;
}
return data;
} catch (e) {
console.error(`Error in incrementViews for ${slug}:`, e);
return null;
}
}
static async getTopViewedMovies(limit = 10) {
try {
const { data, error } = await supabase
.from('movie_views')
.select('slug, views')
.order('views', { ascending: false })
.limit(limit);
if (error) {
console.error('Error fetching top viewed movies:', error);
return [];
}
if (!data || !data.length) return [];
const slugs = data.map(d => d.slug);
const { data: movies, error: moviesError } = await supabase
.from('movies')
.select('slug, movie')
.in('slug', slugs);
if (moviesError) {
console.error('Error fetching movie details for top viewed:', moviesError);
return [];
}
return data
.map(v => {
const m = movies.find(x => x.slug === v.slug);
if (!m) return null;
return {
...m.movie,
views: v.views
};
})
.filter(Boolean);
} catch (e) {
console.error('Error in getTopViewedMovies:', e);
return [];
}
}
static async getTopRatedMovies(limit = 10) {
try {
const { data, error } = await supabase
.from('ratings')
.select('slug, count, total_score');
if (error) {
console.error('Error fetching ratings for top rated:', error);
return [];
}
if (!data || !data.length) return [];
const sorted = data
.map(r => ({
slug: r.slug,
avg: r.count > 0 ? (Number(r.total_score) / r.count) : 0,
count: r.count
}))
.sort((a, b) => b.avg - a.avg || b.count - a.count)
.slice(0, limit);
const slugs = sorted.map(s => s.slug);
const { data: movies, error: moviesError } = await supabase
.from('movies')
.select('slug, movie')
.in('slug', slugs);
if (moviesError) {
console.error('Error fetching movie details for top rated:', moviesError);
return [];
}
return sorted
.map(s => {
const m = movies.find(x => x.slug === s.slug);
if (!m) return null;
return {
...m.movie,
rating: s.avg.toFixed(1),
vote_count: s.count
};
})
.filter(Boolean);
} catch (e) {
console.error('Error in getTopRatedMovies:', e);
return [];
}
}
static async getMostFavoritedMovies(limit = 10) {
try {
const { data: users, error } = await supabase
.from('users')
.select('favorites');
if (error) {
console.error('Error fetching users for favorites count:', error);
return [];
}
const counts = {};
users?.forEach(u => {
const favs = u.favorites || [];
if (Array.isArray(favs)) {
favs.forEach(f => {
if (f.slug) {
counts[f.slug] = (counts[f.slug] || 0) + 1;
}
});
}
});
const sortedSlugs = Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(entry => entry[0]);
if (sortedSlugs.length === 0) return [];
const { data: movies, error: moviesError } = await supabase
.from('movies')
.select('slug, movie')
.in('slug', sortedSlugs);
if (moviesError) {
console.error('Error fetching movie details for most favorited:', moviesError);
return [];
}
return sortedSlugs
.map(slug => {
const m = movies.find(x => x.slug === slug);
if (!m) return null;
return {
...m.movie,
favorite_count: counts[slug]
};
})
.filter(Boolean);
} catch (e) {
console.error('Error in getMostFavoritedMovies:', e);
return [];
}
}
// --- SETTINGS & EMAIL LOGS ---
static async getSiteUrl(request = null) {
let url = await this.getSetting('site_url');
if (!url) {
if (process.env.SITE_URL) {
url = process.env.SITE_URL;
} else if (process.env.VERCEL_URL) {
url = `https://${process.env.VERCEL_URL}`;
} else if (request) {
try {
const host = request.headers.get('host') || request.url ? new URL(request.url).host : null;
const proto = request.headers.get('x-forwarded-proto') || 'http';
url = host ? `${proto}://${host}` : 'http://localhost:4321';
} catch (e) {
url = 'http://localhost:4321';
}
} else {
url = 'http://localhost:4321';
}
}
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
return url;
}
static async getSettings() {
const { data, error } = await supabase
.from('settings')
.select('*');
if (error) {
console.error('Error fetching settings:', error);
return {};
}
const settings = {};
(data || []).forEach(item => {
settings[item.key] = item.value;
});
return settings;
}
static async getSetting(key) {
const { data, error } = await supabase
.from('settings')
.select('value')
.eq('key', key)
.maybeSingle();
if (error) {
console.error(`Error fetching setting ${key}:`, error);
return null;
}
return data ? data.value : null;
}
static async saveSetting(key, value) {
const { data, error } = await supabase
.from('settings')
.upsert({
key,
value,
updated_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error(`Error saving setting ${key}:`, error);
throw error;
}
return data;
}
static async saveSettings(settingsObj) {
const upsertData = Object.entries(settingsObj).map(([key, value]) => ({
key,
value,
updated_at: new Date().toISOString()
}));
const { data, error } = await supabase
.from('settings')
.upsert(upsertData);
if (error) {
console.error('Error saving multiple settings:', error);
throw error;
}
return data;
}
static async getEmailLogs() {
const { data, error } = await supabase
.from('email_logs')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching email logs:', error);
return [];
}
return data;
}
static async saveEmailLog(toEmail, subject, body, templateName, status, errorMessage = null) {
const { data, error } = await supabase
.from('email_logs')
.insert({
to_email: toEmail,
subject,
body,
template_name: templateName,
status,
error_message: errorMessage,
created_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error saving email log:', error);
throw error;
}
return data;
}
static async notifyNewEpisodes(slug, movieName, newEpisodes = []) {
try {
const { data: users, error } = await supabase
.from('users')
.select('id, username, email, favorites');
if (error) throw error;
const interestedUsers = (users || []).filter(u =>
Array.isArray(u.favorites) && u.favorites.some(f => f.slug === slug)
);
if (interestedUsers.length === 0) return;
const settings = await this.getSettings();
if (!settings.smtp_user || !settings.smtp_pass) {
console.log(`[Notification Engine]: SMTP not configured. Skipping notifications for ${movieName}.`);
return;
}
console.log(`[Notification Engine]: Sending new episode notifications for ${movieName} to ${interestedUsers.length} users...`);
const episodeListHtml = newEpisodes.map(ep => `<li>Tập ${ep.name || ep.filename || ep.episode || 'mới'}</li>`).join('');
for (const u of interestedUsers) {
if (!u.email) continue;
try {
await this.sendMail({
to: u.email,
subject: `[TPHIMX] Có tập mới phim "${movieName}" nè bạn ơi!`,
templateName: 'new_episode',
placeholders: {
username: u.username,
movie_name: movieName,
movie_slug: slug,
episode_list: episodeListHtml
}
});
console.log(`[Notification Engine]: Notification sent to ${u.email}`);
} catch (mailErr) {
console.error(`[Notification Engine]: Failed to send notification to ${u.email}:`, mailErr);
}
}
} catch (err) {
console.error('[Notification Engine Error]:', err);
}
}
static async sendMail({ to, subject, templateName, placeholders = {}, smtpConfigOverride = null }) {
try {
const settings = smtpConfigOverride || await this.getSettings();
const host = settings.smtp_host || settings.smtpHost || 'smtp.gmail.com';
const port = Number(settings.smtp_port || settings.smtpPort) || 465;
const secure = (settings.smtp_secure || settings.smtpSecure) === 'SSL' || (settings.smtp_secure || settings.smtpSecure) === 'TLS' || port === 465;
const user = settings.smtp_user || settings.smtpUser || '';
const pass = settings.smtp_pass || settings.smtpPass || '';
const fromEmail = settings.smtp_from_email || settings.smtpFromEmail || 'no-reply@tphimx.com';
const fromName = settings.smtp_from_name || settings.smtpFromName || 'TPHIMX';
if (!user || !pass) {
throw new Error('SMTP credentials are not configured.');
}
// Import nodemailer and fs dynamically
const nodemailer = await import('nodemailer');
const fs = await import('node:fs/promises');
const path = await import('node:path');
// Load template
const templatePath = path.join(process.cwd(), 'src/templates/email', `${templateName}.html`);
let htmlContent = '';
try {
htmlContent = await fs.readFile(templatePath, 'utf-8');
} catch (err) {
console.error(`Template ${templateName} not found at ${templatePath}, falling back to default styling.`);
htmlContent = `<h1>${subject}</h1><p>Welcome to TPHIMX.</p>`;
}
// Replace placeholders
Object.entries(placeholders).forEach(([key, val]) => {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
htmlContent = htmlContent.replace(regex, val);
});
// Also dynamically replace site_url, site_name and copyright_year from configurations if present
const siteUrl = settings.site_url || 'http://localhost:4321';
const siteName = settings.site_name || 'TPHIMX';
const currentYear = new Date().getFullYear();
const copyrightYear = currentYear > 2026 ? `2026 - ${currentYear}` : '2026';
htmlContent = htmlContent.replace(/{{\s*site_url\s*}}/g, siteUrl);
htmlContent = htmlContent.replace(/{{\s*site_name\s*}}/g, siteName);
htmlContent = htmlContent.replace(/{{\s*copyright_year\s*}}/g, copyrightYear);
// Create transporter
const transporter = nodemailer.default.createTransport({
host,
port,
secure,
auth: {
user,
pass,
},
tls: {
rejectUnauthorized: false
}
});
// Send mail
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
subject,
html: htmlContent
});
console.log('Message sent: %s', info.messageId);
// Log success to DB
await this.saveEmailLog(to, subject, htmlContent, templateName, 'sent');
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Error sending email:', error);
// Log failure to DB
try {
await this.saveEmailLog(to, subject, `[Gửi thất bại] ${error.message}`, templateName, 'failed', error.message);
} catch (logErr) {
console.error('Failed to log email failure:', logErr);
}
throw error;
}
}
// --- ZALO ACCESS ---
static async getZaloAccessByToken(token) {
const { data, error } = await supabase
.from('zalo_access')
.select('*')
.eq('browser_token', token)
.maybeSingle();
if (error) {
console.error(`Error fetching Zalo access for token ${token}:`, error);
return null;
}
return data;
}
static async createZaloAccessRequest(token, zaloNickname) {
const { data, error } = await supabase
.from('zalo_access')
.upsert({
browser_token: token,
zalo_nickname: zaloNickname,
status: 'pending',
updated_at: new Date().toISOString()
}, { onConflict: 'browser_token' })
.select()
.single();
if (error) {
console.error(`Error creating Zalo access request for token ${token}:`, error);
throw error;
}
return data;
}
static async updateZaloAccessStatus(id, status) {
const { data, error } = await supabase
.from('zalo_access')
.update({
status,
updated_at: new Date().toISOString()
})
.eq('id', id)
.select()
.single();
if (error) {
console.error(`Error updating Zalo access status for ${id}:`, error);
throw error;
}
return data;
}
static async getAllZaloAccessRequests() {
const { data, error } = await supabase
.from('zalo_access')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching Zalo access requests:', error);
return [];
}
return data;
}
static async getZaloAccessRequests() {
return await this.getAllZaloAccessRequests();
}
// --- REPORTS ---
static async createReport(movieSlug, movieName, episodeName, errorType, description) {
const { data, error } = await supabase
.from('reports')
.insert({
movie_slug: movieSlug,
movie_name: movieName,
episode_name: episodeName,
error_type: errorType,
description,
status: 'pending',
created_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error creating report:', error);
throw error;
}
return data;
}
static async getAllReports() {
const { data, error } = await supabase
.from('reports')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching reports:', error);
return [];
}
return data;
}
static async getReports() {
return await this.getAllReports();
}
static async updateReportStatus(id, status) {
const { data, error } = await supabase
.from('reports')
.update({ status })
.eq('id', id)
.select()
.single();
if (error) {
console.error(`Error updating report status for ${id}:`, error);
throw error;
}
return data;
}
// --- LOGS ---
static async createLog(action, level, message, details = null) {
const { data, error } = await supabase
.from('logs')
.insert({
action,
level,
message,
details: typeof details === 'object' ? JSON.stringify(details) : details,
created_at: new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error creating log:', error);
}
return data;
}
static async getAllLogs() {
const { data, error } = await supabase
.from('logs')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching logs:', error);
return [];
}
return data;
}
}