Modulo makers3d desarrollado con codex V 0.0.1
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { query, one } from "./db.js";
|
||||
|
||||
export const sessionMaxAgeSeconds = 60 * 60 * 24 * 400;
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "user" | "admin";
|
||||
};
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
currentUser: CurrentUser | null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, { type: argon2.argon2id });
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(token);
|
||||
await query(
|
||||
`
|
||||
insert into sessions (user_id, token_hash, expires_at)
|
||||
values ($1, $2, now() + ($3::int * interval '1 second'))
|
||||
`,
|
||||
[userId, tokenHash, sessionMaxAgeSeconds]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function destroySession(token: string): Promise<void> {
|
||||
await query(`delete from sessions where token_hash = $1`, [hashToken(token)]);
|
||||
}
|
||||
|
||||
export async function refreshSession(token: string): Promise<void> {
|
||||
await query(
|
||||
`
|
||||
update sessions
|
||||
set expires_at = now() + ($2::int * interval '1 second')
|
||||
where token_hash = $1
|
||||
and expires_at > now()
|
||||
`,
|
||||
[hashToken(token), sessionMaxAgeSeconds]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserFromSessionToken(token: string): Promise<CurrentUser | null> {
|
||||
return one<CurrentUser>(
|
||||
`
|
||||
select u.id, u.email, u.role
|
||||
from sessions s
|
||||
join users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and s.expires_at > now()
|
||||
`,
|
||||
[hashToken(token)]
|
||||
);
|
||||
}
|
||||
|
||||
export async function attachCurrentUser(request: FastifyRequest): Promise<void> {
|
||||
const token = request.cookies.makers3d_session;
|
||||
if (!token) {
|
||||
request.currentUser = null;
|
||||
return;
|
||||
}
|
||||
|
||||
request.currentUser = await getUserFromSessionToken(token);
|
||||
}
|
||||
|
||||
export function requireAuth(request: FastifyRequest, reply: FastifyReply): CurrentUser | null {
|
||||
if (!request.currentUser) {
|
||||
reply.code(401).send({ error: "Authentication required" });
|
||||
return null;
|
||||
}
|
||||
return request.currentUser;
|
||||
}
|
||||
|
||||
export function requireAdmin(request: FastifyRequest, reply: FastifyReply): CurrentUser | null {
|
||||
if (!request.currentUser || request.currentUser.role !== "admin") {
|
||||
reply.code(403).send({ error: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
return request.currentUser;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const booleanFromEnv = z.preprocess((value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
return false;
|
||||
}, z.boolean());
|
||||
|
||||
const configSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
SESSION_SECRET: z.string().min(8).default("change-me"),
|
||||
API_PORT: z.coerce.number().default(4000),
|
||||
APP_URL: z.string().default("http://localhost"),
|
||||
MINIO_ROOT_USER: z.string().default("minioadmin"),
|
||||
MINIO_ROOT_PASSWORD: z.string().default("minioadmin"),
|
||||
MINIO_ENDPOINT: z.string().default("minio"),
|
||||
MINIO_PORT: z.coerce.number().default(9000),
|
||||
MINIO_USE_SSL: booleanFromEnv.default(false),
|
||||
MINIO_PUBLIC_BUCKET: z.string().default("makers3d-public"),
|
||||
MINIO_PRIVATE_BUCKET: z.string().default("makers3d-private"),
|
||||
PAYMENT_PROVIDER: z.enum(["demo", "mercadopago"]).default("demo"),
|
||||
MERCADOPAGO_ACCESS_TOKEN: z.string().optional(),
|
||||
ADMIN_EMAIL: z.string().email().default("admin@makers3d.local"),
|
||||
ADMIN_PASSWORD: z.string().min(8).default("Admin123!")
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Pool, type QueryResultRow } from "pg";
|
||||
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: config.DATABASE_URL
|
||||
});
|
||||
|
||||
export async function query<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
const result = await pool.query<T>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function one<T extends QueryResultRow>(sql: string, params: unknown[] = []): Promise<T | null> {
|
||||
const rows = await query<T>(sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
full_name text not null,
|
||||
role text not null default 'user' check (role in ('user', 'admin')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists sessions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists maker_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null unique references users(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
business_name text,
|
||||
description text,
|
||||
province text,
|
||||
city text,
|
||||
latitude double precision,
|
||||
longitude double precision,
|
||||
public_latitude double precision,
|
||||
public_longitude double precision,
|
||||
delivery_scope text,
|
||||
availability text not null default 'available' check (availability in ('available', 'limited', 'unavailable')),
|
||||
public_contact_email text,
|
||||
public_whatsapp text,
|
||||
website_url text,
|
||||
instagram_url text,
|
||||
tiktok_url text,
|
||||
twitter_url text,
|
||||
facebook_url text,
|
||||
youtube_url text,
|
||||
linkedin_url text,
|
||||
allow_verified_reviews boolean not null default true,
|
||||
show_satisfaction_score boolean not null default true,
|
||||
show_review_tags boolean not null default true,
|
||||
allow_maker_review_reply boolean not null default true,
|
||||
show_past_reviews boolean not null default true,
|
||||
status text not null default 'draft' check (status in ('draft', 'published', 'suspended')),
|
||||
main_image_url text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table maker_profiles add column if not exists website_url text;
|
||||
alter table maker_profiles add column if not exists instagram_url text;
|
||||
alter table maker_profiles add column if not exists tiktok_url text;
|
||||
alter table maker_profiles add column if not exists twitter_url text;
|
||||
alter table maker_profiles add column if not exists facebook_url text;
|
||||
alter table maker_profiles add column if not exists youtube_url text;
|
||||
alter table maker_profiles add column if not exists linkedin_url text;
|
||||
alter table maker_profiles add column if not exists allow_verified_reviews boolean not null default true;
|
||||
alter table maker_profiles add column if not exists show_satisfaction_score boolean not null default true;
|
||||
alter table maker_profiles add column if not exists show_review_tags boolean not null default true;
|
||||
alter table maker_profiles add column if not exists allow_maker_review_reply boolean not null default true;
|
||||
alter table maker_profiles add column if not exists show_past_reviews boolean not null default true;
|
||||
|
||||
create table if not exists services (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
category text not null,
|
||||
description text not null,
|
||||
technologies text[] not null default '{}',
|
||||
materials text[] not null default '{}',
|
||||
local_pickup boolean not null default true,
|
||||
nationwide_shipping boolean not null default false,
|
||||
lead_time_days integer not null default 3,
|
||||
price_from_cents integer,
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table services add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
|
||||
create table if not exists works (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
service_id uuid references services(id) on delete set null,
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
summary text not null,
|
||||
story text not null,
|
||||
technology text not null,
|
||||
material text not null,
|
||||
image_url text not null,
|
||||
gallery_urls text[] not null default '{}',
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table works add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
|
||||
create table if not exists showcases (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
description text not null,
|
||||
cover_image_url text,
|
||||
selected_work_ids uuid[] not null default '{}',
|
||||
featured_work_id uuid references works(id) on delete set null,
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table showcases add column if not exists cover_image_url text;
|
||||
alter table showcases add column if not exists selected_work_ids uuid[] not null default '{}';
|
||||
alter table showcases add column if not exists featured_work_id uuid references works(id) on delete set null;
|
||||
alter table showcases add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
|
||||
create table if not exists maker_locations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
label text not null,
|
||||
location_type text not null,
|
||||
address text,
|
||||
postal_code text,
|
||||
province text not null,
|
||||
city text not null,
|
||||
latitude double precision,
|
||||
longitude double precision,
|
||||
privacy text not null default 'approximate',
|
||||
local_pickup boolean not null default true,
|
||||
personal_delivery boolean not null default true,
|
||||
postal_shipping boolean not null default true,
|
||||
nationwide_shipping boolean not null default false,
|
||||
mobile_service boolean not null default false,
|
||||
onsite_service boolean not null default false,
|
||||
notes text,
|
||||
travel_radius text,
|
||||
shipping_area text,
|
||||
hours_mode text not null default 'appointment',
|
||||
alert_service text,
|
||||
alert_radius text,
|
||||
minimum_rating text,
|
||||
draft_data jsonb not null default '{}'::jsonb,
|
||||
is_published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table maker_locations add column if not exists draft_data jsonb not null default '{}'::jsonb;
|
||||
alter table maker_locations add column if not exists is_published boolean not null default false;
|
||||
|
||||
create table if not exists subscriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
provider text not null,
|
||||
plan_code text not null,
|
||||
status text not null check (status in ('inactive', 'pending', 'active', 'grace', 'cancelled')),
|
||||
external_reference text,
|
||||
activated_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists inquiries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
customer_id uuid not null references users(id) on delete cascade,
|
||||
source_type text not null,
|
||||
source_id uuid,
|
||||
need_text text not null,
|
||||
size_text text,
|
||||
has_model boolean not null default false,
|
||||
material_preference text,
|
||||
quantity integer not null default 1,
|
||||
urgency text not null,
|
||||
delivery_type text not null,
|
||||
status text not null default 'open' check (status in ('open', 'rejected', 'completed')),
|
||||
rejection_reason text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table inquiries drop constraint if exists inquiries_status_check;
|
||||
alter table inquiries add constraint inquiries_status_check check (status in ('open', 'agreed', 'completed', 'rejected', 'incident'));
|
||||
alter table inquiries add column if not exists proposal_price_cents integer;
|
||||
alter table inquiries add column if not exists proposal_quantity integer;
|
||||
alter table inquiries add column if not exists proposal_lead_time_days integer;
|
||||
alter table inquiries add column if not exists proposal_note text;
|
||||
alter table inquiries add column if not exists proposal_created_at timestamptz;
|
||||
alter table inquiries add column if not exists agreed_at timestamptz;
|
||||
alter table inquiries add column if not exists maker_completed_at timestamptz;
|
||||
alter table inquiries add column if not exists customer_completed_at timestamptz;
|
||||
alter table inquiries add column if not exists completed_at timestamptz;
|
||||
alter table inquiries add column if not exists incident_reason text;
|
||||
alter table inquiries add column if not exists incident_detail text;
|
||||
alter table inquiries add column if not exists incident_created_at timestamptz;
|
||||
update inquiries set completed_at = updated_at where status = 'completed' and completed_at is null;
|
||||
|
||||
create table if not exists messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
inquiry_id uuid not null references inquiries(id) on delete cascade,
|
||||
sender_user_id uuid not null references users(id) on delete cascade,
|
||||
body text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists reviews (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
inquiry_id uuid not null unique references inquiries(id) on delete cascade,
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
customer_id uuid not null references users(id) on delete cascade,
|
||||
rating_overall integer not null check (rating_overall between 1 and 5),
|
||||
rating_quality integer not null check (rating_quality between 1 and 5),
|
||||
rating_communication integer not null check (rating_communication between 1 and 5),
|
||||
rating_value integer not null check (rating_value between 1 and 5),
|
||||
comment text not null,
|
||||
status text not null default 'published' check (status in ('published', 'reported', 'hidden')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists favorite_makers (
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
maker_id uuid not null references maker_profiles(id) on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (user_id, maker_id)
|
||||
);
|
||||
|
||||
create table if not exists favorite_works (
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
work_id uuid not null references works(id) on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (user_id, work_id)
|
||||
);
|
||||
|
||||
create table if not exists moderation_cases (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
target_type text not null,
|
||||
target_id uuid not null,
|
||||
reporter_user_id uuid references users(id) on delete set null,
|
||||
reason text not null,
|
||||
status text not null default 'open' check (status in ('open', 'resolved')),
|
||||
resolution text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
type text not null,
|
||||
payload jsonb not null,
|
||||
status text not null default 'pending' check (status in ('pending', 'processing', 'done', 'failed')),
|
||||
attempts integer not null default 0,
|
||||
last_error text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_maker_profiles_status on maker_profiles(status);
|
||||
create index if not exists idx_services_maker_id on services(maker_id);
|
||||
create index if not exists idx_works_maker_id on works(maker_id);
|
||||
create index if not exists idx_showcases_maker_id on showcases(maker_id);
|
||||
create index if not exists idx_maker_locations_maker_id on maker_locations(maker_id);
|
||||
create index if not exists idx_inquiries_maker_id on inquiries(maker_id);
|
||||
create index if not exists idx_messages_inquiry_id on messages(inquiry_id);
|
||||
create index if not exists idx_favorite_makers_user_id on favorite_makers(user_id);
|
||||
create index if not exists idx_favorite_works_user_id on favorite_works(user_id);
|
||||
create index if not exists idx_jobs_status on jobs(status);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildPublishChecklist, isPublishReady } from "@makers3d/shared";
|
||||
|
||||
test("publish checklist exige suscripcion activa", () => {
|
||||
const checklist = buildPublishChecklist({
|
||||
businessName: "Maker",
|
||||
description: "Descripcion suficiente",
|
||||
province: "CABA",
|
||||
city: "Buenos Aires",
|
||||
servicesCount: 1,
|
||||
worksCount: 1,
|
||||
publicContactEmail: "maker@test.com",
|
||||
subscriptionStatus: "inactive"
|
||||
});
|
||||
|
||||
assert.equal(isPublishReady(checklist), false);
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
import { buildPublishChecklist, isPublishReady, maskCoordinates, slugify, type Availability, type SubscriptionStatus } from "@makers3d/shared";
|
||||
|
||||
import { query, one } from "./db.js";
|
||||
|
||||
type MakerRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
slug: string;
|
||||
business_name: string | null;
|
||||
description: string | null;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
public_latitude: number | null;
|
||||
public_longitude: number | null;
|
||||
delivery_scope: string | null;
|
||||
availability: Availability;
|
||||
public_contact_email: string | null;
|
||||
public_whatsapp: string | null;
|
||||
website_url: string | null;
|
||||
instagram_url: string | null;
|
||||
tiktok_url: string | null;
|
||||
twitter_url: string | null;
|
||||
facebook_url: string | null;
|
||||
youtube_url: string | null;
|
||||
linkedin_url: string | null;
|
||||
allow_verified_reviews: boolean;
|
||||
show_satisfaction_score: boolean;
|
||||
show_review_tags: boolean;
|
||||
allow_maker_review_reply: boolean;
|
||||
show_past_reviews: boolean;
|
||||
status: string;
|
||||
main_image_url: string | null;
|
||||
};
|
||||
|
||||
export async function getMakerByUserId(userId: string) {
|
||||
return one<MakerRow>(`select * from maker_profiles where user_id = $1`, [userId]);
|
||||
}
|
||||
|
||||
export async function getMakerBySlug(slug: string) {
|
||||
return one<MakerRow>(
|
||||
`select * from maker_profiles where slug = $1 and status in ('draft', 'published', 'suspended')`,
|
||||
[slug]
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureMakerProfile(userId: string): Promise<MakerRow> {
|
||||
let profile = await getMakerByUserId(userId);
|
||||
if (profile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const slug = `maker-${userId.slice(0, 8)}`;
|
||||
const rows = await query<MakerRow>(
|
||||
`
|
||||
insert into maker_profiles (
|
||||
user_id, slug, availability, delivery_scope, status
|
||||
) values ($1, $2, 'available', 'local', 'draft')
|
||||
returning *
|
||||
`,
|
||||
[userId, slug]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getSubscriptionStatus(makerId: string): Promise<SubscriptionStatus> {
|
||||
const result = await one<{ status: SubscriptionStatus }>(
|
||||
`select status from subscriptions where maker_id = $1 order by created_at desc limit 1`,
|
||||
[makerId]
|
||||
);
|
||||
return result?.status ?? "inactive";
|
||||
}
|
||||
|
||||
export async function upsertMakerProfile(userId: string, input: {
|
||||
businessName: string;
|
||||
description: string;
|
||||
province: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
deliveryScope: string;
|
||||
availability: Availability;
|
||||
publicContactEmail?: string;
|
||||
publicWhatsapp?: string;
|
||||
mainImageUrl?: string;
|
||||
websiteUrl?: string;
|
||||
instagramUrl?: string;
|
||||
tiktokUrl?: string;
|
||||
twitterUrl?: string;
|
||||
facebookUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
linkedinUrl?: string;
|
||||
}) {
|
||||
const existing = await ensureMakerProfile(userId);
|
||||
const slugBase = slugify(input.businessName || existing.slug || "maker");
|
||||
const masked = maskCoordinates(input.latitude, input.longitude);
|
||||
const rows = await query<MakerRow>(
|
||||
`
|
||||
update maker_profiles
|
||||
set slug = $2,
|
||||
business_name = $3,
|
||||
description = $4,
|
||||
province = $5,
|
||||
city = $6,
|
||||
latitude = $7,
|
||||
longitude = $8,
|
||||
public_latitude = $9,
|
||||
public_longitude = $10,
|
||||
delivery_scope = $11,
|
||||
availability = $12,
|
||||
public_contact_email = $13,
|
||||
public_whatsapp = $14,
|
||||
main_image_url = coalesce($15, main_image_url),
|
||||
website_url = $16,
|
||||
instagram_url = $17,
|
||||
tiktok_url = $18,
|
||||
twitter_url = $19,
|
||||
facebook_url = $20,
|
||||
youtube_url = $21,
|
||||
linkedin_url = $22,
|
||||
updated_at = now()
|
||||
where user_id = $1
|
||||
returning *
|
||||
`,
|
||||
[
|
||||
userId,
|
||||
`${slugBase}-${existing.id.slice(0, 4)}`,
|
||||
input.businessName,
|
||||
input.description,
|
||||
input.province,
|
||||
input.city,
|
||||
input.latitude,
|
||||
input.longitude,
|
||||
masked.latitude,
|
||||
masked.longitude,
|
||||
input.deliveryScope,
|
||||
input.availability,
|
||||
input.publicContactEmail || null,
|
||||
input.publicWhatsapp || null,
|
||||
input.mainImageUrl || null,
|
||||
input.websiteUrl || null,
|
||||
input.instagramUrl || null,
|
||||
input.tiktokUrl || null,
|
||||
input.twitterUrl || null,
|
||||
input.facebookUrl || null,
|
||||
input.youtubeUrl || null,
|
||||
input.linkedinUrl || null
|
||||
]
|
||||
);
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getMakerChecklist(makerId: string) {
|
||||
const maker = await one<MakerRow>(`select * from maker_profiles where id = $1`, [makerId]);
|
||||
if (!maker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const services = await one<{ count: string }>(`select count(*) from services where maker_id = $1 and is_published = true`, [makerId]);
|
||||
const works = await one<{ count: string }>(`select count(*) from works where maker_id = $1 and is_published = true`, [makerId]);
|
||||
const subscriptionStatus = await getSubscriptionStatus(makerId);
|
||||
|
||||
const checklist = buildPublishChecklist({
|
||||
businessName: maker.business_name,
|
||||
description: maker.description,
|
||||
province: maker.province,
|
||||
city: maker.city,
|
||||
servicesCount: Number(services?.count ?? 0),
|
||||
worksCount: Number(works?.count ?? 0),
|
||||
publicContactEmail: maker.public_contact_email,
|
||||
publicWhatsapp: maker.public_whatsapp,
|
||||
subscriptionStatus
|
||||
});
|
||||
|
||||
return {
|
||||
checklist,
|
||||
ready: isPublishReady(checklist),
|
||||
subscriptionStatus
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishMakerIfReady(makerId: string) {
|
||||
const checklistInfo = await getMakerChecklist(makerId);
|
||||
if (!checklistInfo || !checklistInfo.ready) {
|
||||
return { published: false, reason: "Checklist incomplete" };
|
||||
}
|
||||
|
||||
await query(`update maker_profiles set status = 'published', updated_at = now() where id = $1`, [makerId]);
|
||||
return { published: true };
|
||||
}
|
||||
|
||||
export async function listPublicMakers(filters: {
|
||||
q?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
serviceCategory?: string;
|
||||
material?: string;
|
||||
deliveryScope?: string;
|
||||
}) {
|
||||
const params: unknown[] = [];
|
||||
const conditions = [`m.status = 'published'`];
|
||||
let queryExactIndex = 0;
|
||||
let queryLikeIndex = 0;
|
||||
|
||||
if (filters.q) {
|
||||
params.push(filters.q.toLowerCase());
|
||||
queryExactIndex = params.length;
|
||||
params.push(`%${filters.q.toLowerCase()}%`);
|
||||
queryLikeIndex = params.length;
|
||||
conditions.push(`(
|
||||
lower(m.business_name) like $${queryLikeIndex}
|
||||
or lower(m.description) like $${queryLikeIndex}
|
||||
or exists (
|
||||
select 1 from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and (
|
||||
lower(s.title) like $${queryLikeIndex}
|
||||
or lower(s.description) like $${queryLikeIndex}
|
||||
or lower(s.category) like $${queryLikeIndex}
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.technologies) technology
|
||||
where lower(technology) like $${queryLikeIndex}
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.materials) material
|
||||
where lower(material) like $${queryLikeIndex}
|
||||
)
|
||||
)
|
||||
)
|
||||
or exists (
|
||||
select 1 from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and (
|
||||
lower(w.title) like $${queryLikeIndex}
|
||||
or lower(w.summary) like $${queryLikeIndex}
|
||||
or lower(w.story) like $${queryLikeIndex}
|
||||
or lower(w.technology) like $${queryLikeIndex}
|
||||
or lower(w.material) like $${queryLikeIndex}
|
||||
)
|
||||
)
|
||||
)`);
|
||||
}
|
||||
|
||||
if (filters.province) {
|
||||
params.push(filters.province);
|
||||
conditions.push(`m.province = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.city) {
|
||||
params.push(filters.city);
|
||||
conditions.push(`m.city = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.deliveryScope) {
|
||||
params.push(filters.deliveryScope);
|
||||
conditions.push(`m.delivery_scope = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.serviceCategory) {
|
||||
params.push(filters.serviceCategory);
|
||||
conditions.push(`exists (
|
||||
select 1 from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and s.category = $${params.length}
|
||||
)`);
|
||||
}
|
||||
|
||||
if (filters.material) {
|
||||
params.push(filters.material);
|
||||
conditions.push(`exists (
|
||||
select 1 from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and $${params.length} = any(s.materials)
|
||||
)`);
|
||||
}
|
||||
|
||||
const matchScoreSelect = filters.q ? `
|
||||
(
|
||||
case when lower(m.business_name) = $${queryExactIndex} then 160 else 0 end +
|
||||
case when lower(m.business_name) like $${queryLikeIndex} then 90 else 0 end +
|
||||
case when lower(m.description) like $${queryLikeIndex} then 36 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and lower(s.title) = $${queryExactIndex}
|
||||
) then 140 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and lower(s.title) like $${queryLikeIndex}
|
||||
) then 96 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from services s
|
||||
where s.maker_id = m.id
|
||||
and s.is_published = true
|
||||
and (
|
||||
lower(s.description) like $${queryLikeIndex}
|
||||
or lower(s.category) like $${queryLikeIndex}
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.technologies) technology
|
||||
where lower(technology) like $${queryLikeIndex}
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from unnest(s.materials) material
|
||||
where lower(material) like $${queryLikeIndex}
|
||||
)
|
||||
)
|
||||
) then 54 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and lower(w.title) = $${queryExactIndex}
|
||||
) then 150 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and lower(w.title) like $${queryLikeIndex}
|
||||
) then 102 else 0 end +
|
||||
case when exists (
|
||||
select 1
|
||||
from works w
|
||||
where w.maker_id = m.id
|
||||
and w.is_published = true
|
||||
and (
|
||||
lower(w.summary) like $${queryLikeIndex}
|
||||
or lower(w.story) like $${queryLikeIndex}
|
||||
or lower(w.technology) like $${queryLikeIndex}
|
||||
or lower(w.material) like $${queryLikeIndex}
|
||||
)
|
||||
) then 66 else 0 end
|
||||
) as match_score,
|
||||
` : `
|
||||
0 as match_score,
|
||||
`;
|
||||
|
||||
const orderBy = filters.q
|
||||
? `match_score desc, review_count desc, m.business_name asc`
|
||||
: `review_count desc, m.business_name asc`;
|
||||
|
||||
return query(
|
||||
`
|
||||
select
|
||||
m.id,
|
||||
m.slug,
|
||||
m.business_name,
|
||||
m.description,
|
||||
m.province,
|
||||
m.city,
|
||||
m.public_latitude,
|
||||
m.public_longitude,
|
||||
m.delivery_scope,
|
||||
m.availability,
|
||||
m.main_image_url,
|
||||
coalesce(
|
||||
lv.public_locations,
|
||||
case
|
||||
when m.public_latitude is not null and m.public_longitude is not null then jsonb_build_array(jsonb_build_object(
|
||||
'id', m.id,
|
||||
'label', m.business_name,
|
||||
'city', m.city,
|
||||
'province', m.province,
|
||||
'latitude', m.public_latitude,
|
||||
'longitude', m.public_longitude
|
||||
))
|
||||
else '[]'::jsonb
|
||||
end
|
||||
) as public_locations,
|
||||
coalesce(rv.avg_rating, 0) as avg_rating,
|
||||
coalesce(rv.review_count, 0) as review_count,
|
||||
coalesce(sv.service_count, 0) as service_count,
|
||||
coalesce(wv.work_count, 0) as work_count,
|
||||
${matchScoreSelect}
|
||||
coalesce(cv.categories, '{}'::text[]) as categories
|
||||
from maker_profiles m
|
||||
left join (
|
||||
select maker_id, round(avg(rating_overall)::numeric, 1) as avg_rating, count(*) as review_count
|
||||
from reviews
|
||||
where status = 'published'
|
||||
group by maker_id
|
||||
) rv on rv.maker_id = m.id
|
||||
left join (
|
||||
select maker_id, count(*) as service_count
|
||||
from services
|
||||
where is_published = true
|
||||
group by maker_id
|
||||
) sv on sv.maker_id = m.id
|
||||
left join (
|
||||
select maker_id, count(*) as work_count
|
||||
from works
|
||||
where is_published = true
|
||||
group by maker_id
|
||||
) wv on wv.maker_id = m.id
|
||||
left join (
|
||||
select maker_id, array_agg(distinct category order by category) as categories
|
||||
from services
|
||||
where is_published = true
|
||||
group by maker_id
|
||||
) cv on cv.maker_id = m.id
|
||||
left join (
|
||||
select
|
||||
maker_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'label', label,
|
||||
'city', city,
|
||||
'province', province,
|
||||
'latitude', case when privacy = 'exact' then latitude else round(latitude::numeric, 2)::double precision end,
|
||||
'longitude', case when privacy = 'exact' then longitude else round(longitude::numeric, 2)::double precision end
|
||||
)
|
||||
order by created_at desc
|
||||
) as public_locations
|
||||
from maker_locations
|
||||
where is_published = true
|
||||
and latitude is not null
|
||||
and longitude is not null
|
||||
group by maker_id
|
||||
) lv on lv.maker_id = m.id
|
||||
where ${conditions.join(" and ")}
|
||||
order by ${orderBy}
|
||||
`,
|
||||
params
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { pool } from "../db.js";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaCandidates = [
|
||||
join(currentDir, "../db/schema.sql"),
|
||||
join(process.cwd(), "apps/api/src/db/schema.sql"),
|
||||
join(process.cwd(), "dist/apps/api/src/db/schema.sql")
|
||||
];
|
||||
|
||||
let sql: string | undefined;
|
||||
|
||||
for (const schemaPath of schemaCandidates) {
|
||||
try {
|
||||
sql = await readFile(schemaPath, "utf8");
|
||||
break;
|
||||
} catch {
|
||||
// Try the next known location. The runtime container keeps the schema outside dist.
|
||||
}
|
||||
}
|
||||
|
||||
if (!sql) {
|
||||
throw new Error(`Schema file not found. Checked: ${schemaCandidates.join(", ")}`);
|
||||
}
|
||||
|
||||
await pool.query(sql);
|
||||
console.log("Database schema applied");
|
||||
await pool.end();
|
||||
@@ -0,0 +1,403 @@
|
||||
import { slugify } from "@makers3d/shared";
|
||||
|
||||
import { hashPassword } from "../auth.js";
|
||||
import { pool, query, one } from "../db.js";
|
||||
import { getMakerByUserId, publishMakerIfReady, upsertMakerProfile } from "../queries.js";
|
||||
|
||||
type DemoMaker = {
|
||||
businessName: string;
|
||||
category: "Impresion FDM" | "Impresion Resina" | "Diseno 3D" | "Prototipado";
|
||||
focus: string;
|
||||
imageUrl: string;
|
||||
material: string;
|
||||
technology: string;
|
||||
priceFromCents: number;
|
||||
deliveryScope: "local" | "nationwide";
|
||||
availability: "available" | "limited";
|
||||
reviewScore: number;
|
||||
reviewCount: number;
|
||||
};
|
||||
|
||||
async function ensureUser(email: string, password: string, fullName: string, role: "user" | "admin" = "user") {
|
||||
const existing = await one<{ id: string }>(`select id from users where email = $1`, [email]);
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
insert into users (email, password_hash, full_name, role)
|
||||
values ($1, $2, $3, $4)
|
||||
returning id
|
||||
`,
|
||||
[email, passwordHash, fullName, role]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async function ensureActiveSubscription(makerId: string) {
|
||||
await query(
|
||||
`
|
||||
insert into subscriptions (maker_id, provider, plan_code, status, activated_at, expires_at)
|
||||
select $1, 'demo', 'maker-base', 'active', now(), now() + interval '30 days'
|
||||
where not exists (
|
||||
select 1
|
||||
from subscriptions
|
||||
where maker_id = $1
|
||||
and provider = 'demo'
|
||||
and plan_code = 'maker-base'
|
||||
)
|
||||
`,
|
||||
[makerId]
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertService(makerId: string, slug: string, maker: DemoMaker) {
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
insert into services (
|
||||
maker_id, slug, title, category, description, technologies, materials, local_pickup, nationwide_shipping, lead_time_days, price_from_cents, is_published
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, true, $8, $9, $10, true)
|
||||
on conflict (slug) do update
|
||||
set title = excluded.title,
|
||||
category = excluded.category,
|
||||
description = excluded.description,
|
||||
technologies = excluded.technologies,
|
||||
materials = excluded.materials,
|
||||
nationwide_shipping = excluded.nationwide_shipping,
|
||||
lead_time_days = excluded.lead_time_days,
|
||||
price_from_cents = excluded.price_from_cents,
|
||||
is_published = true,
|
||||
updated_at = now()
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
makerId,
|
||||
slug,
|
||||
`${maker.category} para ${maker.focus}`,
|
||||
maker.category,
|
||||
`${maker.businessName} resuelve ${maker.focus} con ${maker.technology} y materiales como ${maker.material}.`,
|
||||
[maker.technology, maker.category === "Diseno 3D" ? "CAD" : "Diseno 3D"],
|
||||
[maker.material, "PLA", "PETG"],
|
||||
maker.deliveryScope === "nationwide",
|
||||
maker.category === "Impresion Resina" ? 4 : 3,
|
||||
maker.priceFromCents
|
||||
]
|
||||
);
|
||||
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async function upsertWork(makerId: string, serviceId: string, slug: string, maker: DemoMaker, index: number) {
|
||||
const workVariantByImage: Record<string, { title: string; summaryTopic: string; storyTopic: string }> = {
|
||||
"/demo/work-coffee-hinge.svg": {
|
||||
title: "Restauracion de engranaje",
|
||||
summaryTopic: "engranaje funcional para cafetera y repuesto mecanico",
|
||||
storyTopic: "una pieza de transmision y recuperacion de movimiento"
|
||||
},
|
||||
"/demo/work-custom.svg": {
|
||||
title: "Soporte funcional",
|
||||
summaryTopic: "soporte tecnico para montaje, fijacion o uso real",
|
||||
storyTopic: "un soporte utilitario para montaje y ajuste"
|
||||
},
|
||||
"/demo/work-dashboard-bracket.svg": {
|
||||
title: "Tablero y carcasa tecnica",
|
||||
summaryTopic: "tablero de control, panel tecnico y carcasa estructural",
|
||||
storyTopic: "un componente de tablero, panel o control tecnico"
|
||||
},
|
||||
"/demo/maker-hero-1.svg": {
|
||||
title: "Prototipo validado",
|
||||
summaryTopic: "prototipo funcional, maqueta y validacion de producto",
|
||||
storyTopic: "una prueba de concepto y validacion de producto"
|
||||
}
|
||||
};
|
||||
const variant = workVariantByImage[maker.imageUrl] || workVariantByImage["/demo/maker-hero-1.svg"];
|
||||
const workTitle = `${variant.title} ${maker.businessName}`;
|
||||
|
||||
await query(
|
||||
`
|
||||
insert into works (
|
||||
maker_id, service_id, slug, title, summary, story, technology, material, image_url, gallery_urls, is_published
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
||||
on conflict (slug) do update
|
||||
set title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
story = excluded.story,
|
||||
technology = excluded.technology,
|
||||
material = excluded.material,
|
||||
image_url = excluded.image_url,
|
||||
gallery_urls = excluded.gallery_urls,
|
||||
is_published = true,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
makerId,
|
||||
serviceId,
|
||||
slug,
|
||||
workTitle,
|
||||
`${variant.summaryTopic} con enfoque en ${maker.material}, ${maker.technology} y entrega clara.`,
|
||||
`${maker.businessName} publico este caso como demostracion de ${maker.focus}. Se documento ${variant.storyTopic}, el problema, la solucion y el material utilizado para facilitar comparacion y contacto.`,
|
||||
maker.technology,
|
||||
maker.material,
|
||||
maker.imageUrl,
|
||||
[maker.imageUrl]
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureInquiryAndReview(makerId: string, makerUserId: string, maker: DemoMaker, customerId: string, reviewIndex: number) {
|
||||
const needText = `Consulta demo ${reviewIndex + 1} para ${maker.businessName}`;
|
||||
let inquiry = await one<{ id: string }>(
|
||||
`select id from inquiries where maker_id = $1 and customer_id = $2 and need_text = $3`,
|
||||
[makerId, customerId, needText]
|
||||
);
|
||||
|
||||
if (!inquiry) {
|
||||
const created = await query<{ id: string }>(
|
||||
`
|
||||
insert into inquiries (
|
||||
maker_id, customer_id, source_type, need_text, size_text, has_model, material_preference, quantity, urgency, delivery_type, status
|
||||
)
|
||||
values ($1, $2, 'work', $3, '12 cm', true, $4, 1, 'medium', 'shipping', 'completed')
|
||||
returning id
|
||||
`,
|
||||
[makerId, customerId, needText, maker.material]
|
||||
);
|
||||
inquiry = created[0];
|
||||
|
||||
await query(
|
||||
`
|
||||
insert into messages (inquiry_id, sender_user_id, body)
|
||||
values
|
||||
($1, $2, 'Hola, quiero algo similar a lo publicado.'),
|
||||
($1, $3, 'Perfecto, te puedo ayudar con ese trabajo.'),
|
||||
($1, $2, 'Buenisimo, avancemos con medidas y entrega.')
|
||||
`,
|
||||
[inquiry.id, customerId, makerUserId]
|
||||
);
|
||||
}
|
||||
|
||||
const roundedRating = Math.max(4, Math.round(maker.reviewScore));
|
||||
await query(
|
||||
`
|
||||
insert into reviews (
|
||||
inquiry_id, maker_id, customer_id, rating_overall, rating_quality, rating_communication, rating_value, comment, status
|
||||
)
|
||||
values ($1, $2, $3, $4, $4, $4, $5, $6, 'published')
|
||||
on conflict (inquiry_id) do update
|
||||
set rating_overall = excluded.rating_overall,
|
||||
rating_quality = excluded.rating_quality,
|
||||
rating_communication = excluded.rating_communication,
|
||||
rating_value = excluded.rating_value,
|
||||
comment = excluded.comment,
|
||||
status = 'published'
|
||||
`,
|
||||
[
|
||||
inquiry.id,
|
||||
makerId,
|
||||
customerId,
|
||||
roundedRating,
|
||||
Math.max(4, roundedRating - 1),
|
||||
`${maker.businessName} respondio bien y entrego una solucion convincente para ${maker.focus}.`
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const adminId = await ensureUser("admin@makers3d.local", "Admin123!", "Admin", "admin");
|
||||
const clientIds = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, index) =>
|
||||
ensureUser(`cliente${index + 1}@makers3d.local`, "Cliente123!", `Cliente Demo ${index + 1}`)
|
||||
)
|
||||
);
|
||||
|
||||
const makers: DemoMaker[] = [
|
||||
{
|
||||
businessName: "MakerLab 3D",
|
||||
category: "Impresion FDM",
|
||||
focus: "repuestos funcionales, ingenieria y piezas de uso real",
|
||||
imageUrl: "/demo/work-coffee-hinge.svg",
|
||||
material: "PETG",
|
||||
technology: "FDM",
|
||||
priceFromCents: 150000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "available",
|
||||
reviewScore: 4.9,
|
||||
reviewCount: 4
|
||||
},
|
||||
{
|
||||
businessName: "PrintCraft",
|
||||
category: "Impresion FDM",
|
||||
focus: "series cortas, soportes tecnicos y repuestos industriales",
|
||||
imageUrl: "/demo/work-custom.svg",
|
||||
material: "ABS",
|
||||
technology: "FDM",
|
||||
priceFromCents: 165000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "available",
|
||||
reviewScore: 4.8,
|
||||
reviewCount: 3
|
||||
},
|
||||
{
|
||||
businessName: "3D Ideas",
|
||||
category: "Diseno 3D",
|
||||
focus: "prototipos, modelado y piezas personalizadas",
|
||||
imageUrl: "/demo/maker-hero-custom.svg",
|
||||
material: "PLA",
|
||||
technology: "CAD",
|
||||
priceFromCents: 180000,
|
||||
deliveryScope: "local",
|
||||
availability: "available",
|
||||
reviewScore: 4.7,
|
||||
reviewCount: 2
|
||||
},
|
||||
{
|
||||
businessName: "ImpresionAR",
|
||||
category: "Impresion Resina",
|
||||
focus: "figuras detalladas, miniaturas y piezas en resina",
|
||||
imageUrl: "/demo/work-dashboard-bracket.svg",
|
||||
material: "Resina estandar",
|
||||
technology: "Resina",
|
||||
priceFromCents: 175000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "available",
|
||||
reviewScore: 4.8,
|
||||
reviewCount: 2
|
||||
},
|
||||
{
|
||||
businessName: "ProtoWorks",
|
||||
category: "Prototipado",
|
||||
focus: "validacion de producto, prototipos funcionales y ajustes rapidos",
|
||||
imageUrl: "/demo/work-custom.svg",
|
||||
material: "PLA",
|
||||
technology: "FDM",
|
||||
priceFromCents: 210000,
|
||||
deliveryScope: "nationwide",
|
||||
availability: "limited",
|
||||
reviewScore: 4.6,
|
||||
reviewCount: 1
|
||||
},
|
||||
{
|
||||
businessName: "Resin Forge",
|
||||
category: "Impresion Resina",
|
||||
focus: "piezas finas, moldes chicos y detalle visual en resina",
|
||||
imageUrl: "/demo/work-dashboard-bracket.svg",
|
||||
material: "Resina alta definicion",
|
||||
technology: "Resina",
|
||||
priceFromCents: 195000,
|
||||
deliveryScope: "local",
|
||||
availability: "available",
|
||||
reviewScore: 4.9,
|
||||
reviewCount: 1
|
||||
},
|
||||
...[
|
||||
"RepuestoLab",
|
||||
"CadSur Studio",
|
||||
"Volumen 3D",
|
||||
"Nodo Maker",
|
||||
"Delta Print",
|
||||
"Origen 3D",
|
||||
"Atlas Makers",
|
||||
"Prisma Resina",
|
||||
"FabCordoba",
|
||||
"Punto Layer",
|
||||
"Taller Capa",
|
||||
"Linea 3D",
|
||||
"ProtoRio",
|
||||
"Hexa Print",
|
||||
"Monoblock 3D",
|
||||
"Cubo Norte",
|
||||
"Rayo Maker",
|
||||
"Ruta FDM",
|
||||
"Andes Prototype",
|
||||
"Sur Layers",
|
||||
"Pixel Resina",
|
||||
"Brio 3D",
|
||||
"Arco Maker",
|
||||
"Nexa CAD",
|
||||
"Torno 3D",
|
||||
"Faro Print",
|
||||
"Cima Resina",
|
||||
"Plano 3D",
|
||||
"Malla Lab",
|
||||
"Vector Maker"
|
||||
].map<DemoMaker>((businessName, index) => {
|
||||
const categoryCycle = [
|
||||
"Impresion FDM",
|
||||
"Diseno 3D",
|
||||
"Impresion Resina",
|
||||
"Prototipado"
|
||||
] as const;
|
||||
const imageCycle = [
|
||||
"/demo/work-coffee-hinge.svg",
|
||||
"/demo/work-custom.svg",
|
||||
"/demo/work-dashboard-bracket.svg",
|
||||
"/demo/maker-hero-1.svg"
|
||||
];
|
||||
const focusCycle = [
|
||||
"repuestos, soportes tecnicos y piezas funcionales",
|
||||
"modelado, diseno 3D y preparacion de archivos",
|
||||
"resina, detalle fino y miniaturas",
|
||||
"prototipos, validacion y ajustes de producto"
|
||||
];
|
||||
const materialCycle = ["PETG", "PLA", "Resina estandar", "ABS"] as const;
|
||||
const technologyCycle = ["FDM", "CAD", "Resina", "FDM"] as const;
|
||||
|
||||
return {
|
||||
businessName,
|
||||
category: categoryCycle[index % categoryCycle.length],
|
||||
focus: focusCycle[index % focusCycle.length],
|
||||
imageUrl: imageCycle[index % imageCycle.length],
|
||||
material: materialCycle[index % materialCycle.length],
|
||||
technology: technologyCycle[index % technologyCycle.length],
|
||||
priceFromCents: 145000 + index * 5000,
|
||||
deliveryScope: index % 3 === 0 ? "nationwide" : "local",
|
||||
availability: index % 5 === 0 ? "limited" : "available",
|
||||
reviewScore: 4.4 + (index % 5) * 0.1,
|
||||
reviewCount: index % 3
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
for (const [index, makerSpec] of makers.entries()) {
|
||||
const makerEmail = `maker${index + 1}@makers3d.local`;
|
||||
const makerUserId = await ensureUser(makerEmail, "Maker123!", makerSpec.businessName);
|
||||
const latitude = -31.4201 + (index % 6) * 0.009;
|
||||
const longitude = -64.1888 + (index % 5) * 0.011;
|
||||
|
||||
await upsertMakerProfile(makerUserId, {
|
||||
businessName: makerSpec.businessName,
|
||||
description: `${makerSpec.businessName} ofrece ${makerSpec.focus} en Cordoba, con foco en ${makerSpec.category.toLowerCase()}.`,
|
||||
province: "Cordoba",
|
||||
city: "Cordoba",
|
||||
latitude,
|
||||
longitude,
|
||||
deliveryScope: makerSpec.deliveryScope,
|
||||
availability: makerSpec.availability,
|
||||
publicContactEmail: makerEmail,
|
||||
publicWhatsapp: `+5493510000${String(index + 1).padStart(3, "0")}`,
|
||||
mainImageUrl: makerSpec.imageUrl
|
||||
});
|
||||
|
||||
const makerProfile = await getMakerByUserId(makerUserId);
|
||||
if (!makerProfile) {
|
||||
throw new Error(`Maker profile missing for ${makerSpec.businessName}`);
|
||||
}
|
||||
|
||||
await ensureActiveSubscription(makerProfile.id);
|
||||
|
||||
const baseSlug = slugify(makerSpec.businessName);
|
||||
const serviceId = await upsertService(makerProfile.id, `${baseSlug}-servicio-demo`, makerSpec);
|
||||
await upsertWork(makerProfile.id, serviceId, `${baseSlug}-trabajo-demo`, makerSpec, index);
|
||||
await publishMakerIfReady(makerProfile.id);
|
||||
|
||||
for (let reviewIndex = 0; reviewIndex < makerSpec.reviewCount; reviewIndex += 1) {
|
||||
const customerId = clientIds[(index + reviewIndex) % clientIds.length];
|
||||
await ensureInquiryAndReview(makerProfile.id, makerUserId, makerSpec, customerId, reviewIndex);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Seed completed. Admin=${adminId} Makers=${makers.length}`);
|
||||
await pool.end();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Client } from "minio";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const storage = new Client({
|
||||
endPoint: config.MINIO_ENDPOINT,
|
||||
port: config.MINIO_PORT,
|
||||
useSSL: config.MINIO_USE_SSL,
|
||||
accessKey: config.MINIO_ROOT_USER,
|
||||
secretKey: config.MINIO_ROOT_PASSWORD
|
||||
});
|
||||
|
||||
export async function ensureBuckets(): Promise<void> {
|
||||
for (const bucket of [config.MINIO_PUBLIC_BUCKET, config.MINIO_PRIVATE_BUCKET]) {
|
||||
const exists = await storage.bucketExists(bucket).catch(() => false);
|
||||
if (!exists) {
|
||||
await storage.makeBucket(bucket, "us-east-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPublicObject(fileName: string, contentType: string, buffer: Buffer): Promise<string> {
|
||||
const objectName = `${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9.\-_]/g, "-")}`;
|
||||
await storage.putObject(config.MINIO_PUBLIC_BUCKET, objectName, buffer, buffer.length, {
|
||||
"Content-Type": contentType
|
||||
});
|
||||
return objectName;
|
||||
}
|
||||
|
||||
export async function getPublicObject(objectName: string) {
|
||||
return storage.getObject(config.MINIO_PUBLIC_BUCKET, objectName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user