Open105

video-distribution-service (学習メモ)

maiamitoriomaiamitorio

actionsディレクトリのなかのserver側のtsファイルは、デフォルトで'use server'が備わっているわけではないので、ファイルの頭に'use server'をつけることを忘れてはいけない

maiamitoriomaiamitorio

NextUiのButtonでsupabaseのloginとsign up のロジックを組んでいたが、Buttonコンポーネントのformactionは機能しなかった。だから、htmlのbuttonを使用した

maiamitoriomaiamitorio

drizzle orm を使うときは、

npm i react
npm i drizzle-orm
npm i -D drizzle-kit

をして

npm i dotenv
maiamitoriomaiamitorio

drizzle ORMとsupabaseのdatabaseをつなげるときのstring variable
https://supabase.com/dashboard/project/_/settings/database

Database Settingsに移動し、Connection StringセクションからURIをコピーする。接続プーリングを使用していることを確認してください。パスワード・プレースホルダを実際のデータベース・パスワードに置き換えることを忘れないでください。

maiamitoriomaiamitorio

スキーマ作った

import {pgTable, serial, text} from "drizzle-orm/pg-core";

export const user=pgTable('user',{
    id:serial('id').primaryKey(),
    userName:text('user_name').notNull(),
    email:text('email').notNull(),
    password:text('password').notNull()
})
maiamitoriomaiamitorio

schemaをpushできなくて足踏みしている
drizzleを使ってsupabaseのdatabaseにアクセスしたい

maiamitoriomaiamitorio

DATABASE_URLの[YOUR-PASSWORD]のところを変えていなかった

maiamitoriomaiamitorio

どうやら、auth認証はできていたみたい
ただ、supabaseのauth management にuserが追加されないので、これを解決しに行く

maiamitoriomaiamitorio
const supabase=createClient()
const {data}=await supabase.auth.getUser();

でuserのデータがあることがわかった
最終的には、userごとにページを出したい
そのために
・drizzleでsupabaseにデータを渡す処理を作成する

maiamitoriomaiamitorio

db.tsに

import as * schema from "./schema"


const db=drizzle(client,{schema});

を追加

maiamitoriomaiamitorio
const supabase=createClient();
const {data,error}=await supabase.auth.getUser();
if(error||!data.user){
redirect('/login)
}

これをサーバー側なら、createClientをserverからとってこれば、ログインした場合にしか表示されないページになる

maiamitoriomaiamitorio

signupの方にdatabaseへのinsertをしなければいけないことに今気づいた
また、databaseへのinsertのときに、

no overloads matches call...

のエラーが出て格闘していたが、valuesのところにカーソルをあてて詳細をみたら、userIdとemailの型が、string || undefinedになっていた。これにより、undefinedの可能性があり、insertできなかった。
よって下記のようにundefinedの可能性を消した

if(error||!data.user?.id||!data.user?.email||!data.user)

emailに関しては、

await db.insert(user).values({
userId:data.user.id,
email:formUser.email,
password:formUser.password
})

とすれば、if文からemailの箇所を消去しても良い

maiamitoriomaiamitorio

この後は、databaseに入っている、つまりsignupされたユーザーはdatabaseにつかされない処理を書く
そのために、queryでdatabaseからuserテーブルのdataをとってくる

maiamitoriomaiamitorio

signInのときの

  const {data,error}=await supabase.auth.signInWithPassword(formUserData)

のdataにはuser情報が入っていた

maiamitoriomaiamitorio

queryファイルの

import {cache} from "react";
export const getAccounts=cache(async()=>{

})

とcacheを付けた

maiamitoriomaiamitorio

drizzleだけではauthはできなそう。SQLを書くしかない
いろいろ試したがdata:userが邪魔をする

maiamitoriomaiamitorio

.envファイルのNEXT_PUBLIC_SUPABASE_URLとNEXT_PUBLIC_SUPABASE_ANON_KEYの値がsupabaseの別のプロジェクトのを使っていた。
直したら、Userに入った。時間を使った結果この初歩的なミスだったのがつらいけど、うれしさの方が大きい。
今後、ちゃんと.envファイルの環境変数の値が正しいのかを確認する
自分がやっていたことは、間違っていなかったことがなにより嬉しい
https://supabase.com/docs/guides/auth/server-side/nextjs

maiamitoriomaiamitorio

errorページの表示に苦戦していたが、supabaseのmiddlewareの下の方を訂正した。

supabase/middleware.ts
...
 const {
        data: { user },
    } = await supabase.auth.getUser()

    if (!user&&!request.nextUrl.pathname.startsWith('/error') && !request.nextUrl.pathname.startsWith('/login')&&!request.nextUrl.pathname.startsWith('/auth')) {
        // no user, potentially respond by redirecting the user to the login page
        const url = request.nextUrl.clone()
        url.pathname = '/login'
        return NextResponse.redirect(url)
    }

    return supabaseResponse
maiamitoriomaiamitorio

query.tsにgetVideoを作成した
useactionsというserver actionファイルを作成した
そこに、postVideoという関数を作成した

maiamitoriomaiamitorio

supabase storageにvideo bucketを作成した。
そして、<innput type="file"/>でアップロードできるようにしている

maiamitoriomaiamitorio

uploadするとき、fileのpathをdatabaseにおくる。
すべての動画を表示する時:
自分の動画だけ表示:userIdを含むもの.schemaの構造を利用。

maiamitoriomaiamitorio

計画

  • アップロード順になるように並び替える
  • データベースに代入
maiamitoriomaiamitorio

アップロード順になるようにロジックを組む

const videos=await getVideo();
if(video){
video.sort((a,b)=>{
//a, b は配列の中の要素を見る
cosnst dateA=new Date(a.updated_at);
const dateB=new Date(b.updated_at);
return dateB.getTime()-date.A.getTime()
//baのときは大きい順、abのときは小さい順
})
}

maiamitoriomaiamitorio

databaseにvideoのtitle,description,videoSrcを入れる。

 const video={
                videoTitle:videoInformation.videoTitle,
                description:videoInformation.description,
                videoSrc:filePath,
                user_id:userId
            }
           postVideo(video)

postVideoは以下

userActtions.ts
export const postVideo=async(video:video)=>{
await db.insert(videos).values({
  videoTitle: video.videoTitle,
        videoSrc:video.videoSrc,
        description: video.description,
        user_id: video.user_id
})
revalidatePth('/','layout')
}

queryにgetUserVideoを作成

query.ts
export const getUserVideo=cache(async(userId:string)=>{
const data=await db.query.videos.findMany({
where:eq(videos.user_id,useId)
})
return data;
})
maiamitoriomaiamitorio

新たな問題
storageのvideoを消しても、databaseの値が消えないのでエラーが出る
storageとdatabaseをつなげる

maiamitoriomaiamitorio
Error: Text content does not match server-rendered HTML.
See more info here: https://nextjs.org/docs/messages/react-hydration-error

の発生
hydration-errorとは
hydration:htmlにjavascriptを埋め込むこと。webページの生成は、ssrの場合、サーバーから静的なhtmlを送り、後でjavascriptを追加する。このことをhydration(水分補給)。からっからのhtmlにjavascriptを入れて水分補給するイメージ。

maiamitoriomaiamitorio
 {userVideo.reverse.map((video) => {
                    return (
                        <div key={video.id}>
                            <Card video={video}/>
                        </div>
                    )
                })}

userVideoの後ろのreverseを消したら解決した。おそらく、reverseはjavascriptなので、一回画面を表示した後に、reverseが行われるせいで、投げたhtmlと表示したhtmlに差異が生まれていた

maiamitoriomaiamitorio

'use client'つけるとリロードしないときれいに動画が現れない

流れ
はじめにCardとかのhtmlをつくる。このときプロップスには何もない。そのあとサーバーから、videoのデータがとどく。

maiamitoriomaiamitorio

layoutの{children}を持つ要素にw-fullを適用させる。じゃないと、変なところでwidthがきれる

maiamitoriomaiamitorio

ひらめいたかも。
client側とserver側を極限まで分ける。

//privateVideo.tsx  (server側)
    <div key={video.id} className={"bg-rose-500"}>
                            <Card video={video}/>
                            {/*<input type={"checkbox"} onChange={()=>handleCheck(video.videoSrc)}/>*/}
                            {/*<button onClick={onDelete(video.videoSrc)} type={"button"}>Delete</button>*/}
                        </div>

//card.tsx  (まだserver側)

  <div  className={"flex flex-col items-center justify-center bg-green-500"}>
            <iframe src={process.env.CDNURL + video.videoSrc} width={320} height={240}
                    allowFullScreen={true} loading={"eager"}/>
            <h1 className={"text-2xl"}>{video.videoTitle}</h1>
            <p>{video.description}</p>
            <DeleteButton/>    //(こいつがclient側)
        </div>

//delte-button.tsx

 <div>
           <button>消す</button>
        </div>

必要のないところまで、clientにしてた

maiamitoriomaiamitorio

Deleteボタンの中にshadcnUiを使用した。
Uploadのほうにも追加した。そのとき、server sideで回せるのではないかと思ってformActionとかでやってみたら、uploadはできるが、alertが出なかったのでやめた。useActionStateを使おうとしたが、React19がNext.jsではまだだったので無理だった。

maiamitoriomaiamitorio

別ファイル(use server)で定義した関数をクライアント側で呼び出してたけど、値が返ってこなかった。そのため、クライアント側でthumbnailをアップロードする処理を書いた。

const file =packInfo.thumbnail
const fileName=file.name.split('.').shift();
const fileExt=file.name.split('.').pop();
const filePath='${fileName}-${Math.random()}.${fileExt}`;

const {error:uploadError}=await supabase.storage.from("thumbnail").upload(filePath,file)

const packFront={
packTitle:packInfo.packTitle,
description:packInfo.description,
thumbnail:filePath,
userId:userId
}
await postPackFront(packFront)
if(uploadError){
throw uploadError
}
maiamitoriomaiamitorio

ロジック:ボタンを押す、packを作る(packIdを作成)、packIdをとってくる、packVideoを作成し、packIdをつける

const MyPack=await getPackId(userId)
if(!MyPack){
return;
}
const packId=MyPack.reverse()[0].id   //最後に入ったpackIdを取り出している
for(const video of videos){
const file=video.file;
const fileName=file.name.split('.').shift();
const fileExt=file.name.split('.').pop();
const filePath=`${fileName}-${Math.random()}.${fileExt}`;

const {error:uploadError}=await supabase.storage.from('packVideo').upload(filePath,file);
const packVideoInfo={
videoTitle:vieo.videoTitle,
description:video.description,
file:filePath,
packId:packId
}

awiat postPackEachVideo(packVideoInfo);
if(uploadError){
throw uploadError;
}
}
maiamitoriomaiamitorio

packを作成する画面
同じ形の関数をコンパクトにする

const handleChange=(setter:React.Dispatch<React.SetStateAction<any>>,field:string)=>(newValue:string|File)=>{
setter((prevState:any)=>({...prevState,[field]:newValue}))
}

return(
<>
<PackHeader onPackTitleChange={handleChange(setPackInfo,'packTitle')} onDescriptionChange={handleChange(setPackInfo,'description')} onThumbnailChange={handleChange(setPackInfo,'thumbnail')}/>
</>
)
const PackHeader=()=>{
const handlePackTitleChange=(event:React.ChangeEvent<HTMLInputElement>)=>{
onPackTitleChange(event.target.value)
}

...

return(
<>
<input type="text" onChange={handlePackTitleChange}/>
...
</>
)
}
maiamitoriomaiamitorio

client側で、envファイルのキーを使うとき、NEXT_PUBLICを先頭につけなければいけない

maiamitoriomaiamitorio

re-resizeをダウンロードしてユーザーがサイズを変更できるようにしている

maiamitoriomaiamitorio

・postVideoのページから、教材をアップロードできるようにする
・それぞれのlessonで表示させる

maiamitoriomaiamitorio

packInfoに教材:Fileを追加して、schemaとuserActionsを変更する

maiamitoriomaiamitorio

server actionには、plain objectとfew-built ins しか送れない

maiamitoriomaiamitorio

customAlertを作成した。

export const CustomAlert=()=>{
return(
 <div className={"fixed inset-0 flex items-center justify-center bg-black/80 z-50"}>
            <div className="flex items-center flex-col gap-y-4 bg-white p-6 rounded-lg shadow-lg w-80">
                <div>
                {success?(
                    <Image src={"/happy-panda.jpg"} alt={""} width={150} height={150} className={"rounded-lg"}/>
                ):(
                    <Image src={"/sad-panda.jpg"} alt={""} width={150} height={150} className={"rounded-lg"}/>
                )}
                </div>
                <p className={" text-3xl text-gray-700"}>
                    {message}
                </p>
                <Button variant={"default"} onClick={onClose} >Close</Button>
            </div>
        </div>
)
}
maiamitoriomaiamitorio
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { supabase } from '@/lib/supabaseClient';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2022-11-15',
});

export async function POST(request: Request) {
  const { name, price, videoUrl } = await request.json();

  try {
    // Stripeで商品を作成
    const product = await stripe.products.create({
      name,
      type: 'good',
      metadata: {
        videoUrl,
      },
    });

    const stripePrice = await stripe.prices.create({
      product: product.id,
      unit_amount: price * 100,
      currency: 'jpy',
    });

    // Supabaseに商品情報を保存
    const { data, error } = await supabase
      .from('products')
      .insert({
        name,
        price,
        video_url: videoUrl,
        stripe_product_id: product.id,
        stripe_price_id: stripePrice.id,
      })
      .select()
      .single();

    if (error) throw error;

    return NextResponse.json({ productId: data.id, stripeProductId: product.id, stripePriceId: stripePrice.id });
  } catch (error) {
    console.error('Error creating product:', error);
    return NextResponse.json({ error: 'Product creation failed' }, { status: 500 });
  }
}
// app/components/ProductForm.tsx
'use client';

import { useState } from 'react';

export default function ProductForm() {
  const [name, setName] = useState('');
  const [price, setPrice] = useState('');
  const [videoUrl, setVideoUrl] = useState('');
  const [message, setMessage] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setMessage('商品を作成中...');
    try {
      const response = await fetch('/api/create-product', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, price: Number(price), videoUrl }),
      });
      const data = await response.json();
      if (response.ok) {
        setMessage('商品が正常に作成されました!');
        setName('');
        setPrice('');
        setVideoUrl('');
      } else {
        setMessage(`エラー: ${data.error}`);
      }
    } catch (error) {
      setMessage('商品の作成中にエラーが発生しました');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 入力フィールドは前回と同じ */}
      <button type="submit">商品を追加</button>
      {message && <p>{message}</p>}
    </form>
  );
}

https://claude.ai/chat/a0a3cac0-2759-484a-9df5-ad1d0a2f8f5d

maiamitoriomaiamitorio

Error: Failed to parse src "undefinedthumbnail/Nagoya castle-w991jc.jpg" on next/image, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)

が発生

解決策:'use client'のときは、環境変数を使うためには、NEXT_PUBLIC_を付けなければいけない。

maiamitoriomaiamitorio

stripeの

    const stripePrice=await stripe.prices.create({
            product:product.id,
            unit_amount:price*100,
            currency:'usd',
        })

unit_amountのところは、100倍することを忘れずに。でないと、priceがすくなすぎて怒られる。

maiamitoriomaiamitorio

stripeのwebhookを追加して、データベースに購入履歴を追加しようとしたが、webhookが実行されない。テストモードのエンドポイントの設定が間違っているのかもしれない。本番環境にした後にもう一度考える。とりあえず、全体的なデザインを行う

maiamitoriomaiamitorio

bcryptをnext.jsにimportするときは、

npm i --save-dev @types/bcrypt

を行って
ルートディレクトリにbcrypt.d.tsファイルを作成して

bcrypt.d.ts
declare module 'bcrypt';
maiamitoriomaiamitorio

Google認証を導入したときは、

const {data:{session},error}=await supabase.auth.getSession()
if(error||!session){
redirect('/auth/login')
}

とすれば、普通の認証でも使える

編集後
上のコードは、セキュリティの関係上やめた方が良い

 const supabase=createClient()
    const {data,error}=await supabase.auth.getUser();
    console.log(data.user?.user_metadata)
    if(error||!data){
        redirect("/auth/login")
    }
maiamitoriomaiamitorio

tutorに金が入るように設定する。databaseのスキーマを組みなおす

maiamitoriomaiamitorio

反省

データベース設計しっかりとはじめにやるべきだった
後になっての変更大変

maiamitoriomaiamitorio
Module not found: Can't resolve 'net'

https://nextjs.org/docs/messages/module-not-found

Import trace for requested module:
maiamitoriomaiamitorio

stripeのwebhookの実装をおこなった

stripe.ts
import Stripe from "stripe";
import {updatePurchaseHistory} from "@/actions/userActtions";
import {NextApiRequest, NextApiResponse} from "next";
import {buffer} from 'micro'
import {redirect} from "next/navigation";

const stripe=new Stripe(process.env.STRIPE_SECRETKEY!)
const endpointSecret=process.env.STRIPE_WEBHOOK_SECRET!

export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(req.method=='POST'){
const buf=await buffer(req); // バッファリング(一時的にメモリーにデータを貯めている。これにより、パフォーマンスが向上する)
const sig=req.headers['stripe-signature'] as string;
let event;

try{
event =stripe.webhooks.constructEvent(buf,sig,endpointSecret)
}catch(err:any){
console.error('Error verifying webhook signature:',err);
return res.status(400).send(`webhook Error: ${err.message}`);

}
const session=event.data.object as Stripe.Checkout.Session;

const userId=session.metadata?.userId;
const priceId=session.metadata?.priceId;

switch(event.type){
case 'checkout.session.completed':
try{
await updatePurchaseHistory(userId,priceId);
console.log('Purchase history updated successfully')
}catch(e){
console.error('Error updating purchase history:',e)
return res.status(500).json({e:'Failed to update purchase history'})

}
break;
case'invoice.payment_succeeded':
//ここにinvoice.payment_succeededイベントの処理
break;
default:console.log(`Unhandled event type:${event.type}`);
}
res.status(200).json({received:true})
}else{
res.setHeader('Allow','POST');
res.status(405).end('Method Not Allowed')
}
}

export const config={
api:{
bodyParser:false
}
}

maiamitoriomaiamitorio

map関数:ある配列の要素を加工して、新たな配列をつくる。

server.js
const list=[5,9,3,1,2,8,4,7,6,11,13,10,15,14,12]

const newList=list.map((item)=>{
    return item+1
})

//[
   6, 10,  4,  2,  3,  9,
   5,  8,  7, 12, 14, 11,
  16, 15, 13
]

maiamitoriomaiamitorio

sort:順番を変える。

index.js
const list=[
    {id:2,
        name:"a",
    },
    {id:1,
        name:"b",
    },
    {id:4,
        name:"c",
    },
    {id:3,
        name:"d",
    }
]

list.sort((a,b)=>b.id-a.id);

console.log(list)
maiamitoriomaiamitorio

stripeのwebhookようやくできた
NextResponseをつかうこと、webhook/route.tsにすることがポイント

route.ts
import Stripe from "stripe";
import {headers} from "next/headers";
import {NextResponse} from "next/server"

const stripe=new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret=process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req:Request){
const body=await req.text();
const sig=headers().get('Stripe-Signature') as string;

let event:Stripe.Event;

try{
event=stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOKS_SECRET!
)
console.log('event',event)
}catch(err){
return new NextResponse("invalid signature",{status:400})
}
const session=event.data.object as Stripe.Checkout.Session;
console.log('session',session)
switch(event.type){
case 'checkout.session.completed':
//決済成功時にさせたい処理を書く
break;
case 'invoice.payment_succeeded':
//請求書の作成成功時の処理を書く
break;
default:console.log(`Unhandled event type:${event.type}`);//上のcaseに一致しなかったとき
}

return new NextResponse("ok",{status:200})
}

maiamitoriomaiamitorio

以下のエラー

 ⨯ Detected default export in 'C:\Users\torio\Desktop\Next.js\content-service\app\api\account\route.ts'. Export a named export for each HTTP method instead.
 ⨯ No HTTP methods exported in 'C:\Users\torio\Desktop\Next.js\content-service\app\api\account\route.ts'. Export a named export for each HTTP method.
router.ts
//account

export async function POST(){
...
}

ページの一番上にあるコメントを消したら、解決した

maiamitoriomaiamitorio

webhookの中では、

const {data, error}=await supabase.auth.getUser()

は使えない

maiamitoriomaiamitorio

反省点:データベースを操作する関数、stripeに関する関数など、項目別に分けないと、後で脆弱性などについて確認する時に時間がかかる

maiamitoriomaiamitorio

stripeの税金を含めた支払いにするためには、checkout.session.createをいかのようにする。
ポイントは、

automatic_tax:{enabled:true},
customer:customerId,
custmer_update:{
address:"auto",
shipping:"auto"
}
stripe.checkout.sessions.create({
                payment_method_types: ['card'],
                line_items: [
                    {
                        price: priceId,
                        quantity: 1
                    },
                ],
                mode: 'payment',
                success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/settlement-result/success?session_id={CHECKOUT_SESSION_ID}`,
                cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/shop`,
                automatic_tax: {enabled: true},
                customer: customerId,
            customer_update:{
                    address:"auto",
                    shipping:"auto"
            },
                payment_intent_data: {
                    application_fee_amount: 123,
                    transfer_data: {
                        destination: accountId
                    }
                },
                metadata: {
                    userId: userId,
                    priceId: priceId
                },
                invoice_creation: {
                    enabled: true,
                    invoice_data: {
                        description: '購入された商品となります',
                        metadata: {
                            userId: userId
                        }
                    }
                },
            },
        );

https://docs.stripe.com/tax/checkout?locale=ja-JP

maiamitoriomaiamitorio

反省点

  • 関数を細かく分ける
  • 関数の名前を丁寧につける 後からでいいと考えがち(コンポーネントの名前も含む)
  • レスポンシブデザインをしながら実装する
  • コミット文にプレフィックスをつける
  • figmaでデザインを決めておく
  • データベース構造を決めておく
  • 機能を決めておく
  • コメントは消しておく
  • 使っていないモジュールは消す
  • バンドルサイズを考えてモジュールをインストールする
  • エラーが発生したらまず仮説を立てる
maiamitoriomaiamitorio

radix uiで使っていないものを消去して、パフォーマンスを改善した

maiamitoriomaiamitorio

学んだこと

  • 反省点の内容
  • 細かくコミットすること
maiamitoriomaiamitorio

今回のプロジェクトの目的

  • 自分でドキュメントを読んで考えて制作すること
  • Youtubeは最終手段。まずdocumentだけで理解する。

成果

  • 自力で開発する土台を作ることができた
  • 反省点を見つけることができた
  • 開発の全体の流れを知ることができた
  • SEOやパフォーマンスなど、コードを書く以外の技術を知ることができた