Compare commits

..

15 Commits

Author SHA1 Message Date
4236e44df4 Merge pull request 'fix: reset the filters when the "search all" is hit' (#97) from fix/filter-clear into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 2m44s
Reviewed-on: #97
2026-03-23 22:19:37 -07:00
Hayden Hargreaves
9c2c7f976f fix: reset the filters when the "search all" is hit 2026-03-23 22:19:03 -07:00
3662ced22b Merge pull request 'fix: fixed domain in UI settings of cookie' (#96) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m2s
Reviewed-on: #96
2026-03-12 20:24:09 -07:00
4645d61d65 Merge branch 'master' into dev 2026-03-12 20:24:05 -07:00
Hayden Hargreaves
cb6ac7610c Merge branch 'dev' of gitea:azpect/Potion into dev 2026-03-12 20:23:42 -07:00
Hayden Hargreaves
57ffa49c5b fix: fixed domain in UI settings of cookie 2026-03-12 20:23:22 -07:00
787fff6bb0 Merge pull request 'dev' (#95) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 53s
Reviewed-on: #95
2026-03-12 20:18:06 -07:00
34080466bd Merge branch 'master' into dev 2026-03-12 20:17:40 -07:00
Hayden Hargreaves
23d426ad71 Merge branch 'dev' of gitea:azpect/Potion into dev 2026-03-12 20:17:01 -07:00
Hayden Hargreaves
bacb070e6d fix: Using UI to set the cookie instead of server. 2026-03-12 20:16:45 -07:00
acb1ed1fd3 Merge pull request 'Trying to fix cookies.' (#94) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 3m45s
Reviewed-on: #94
2026-03-12 19:39:03 -07:00
8aa748d7ed Merge branch 'master' into dev 2026-03-12 19:38:56 -07:00
Hayden Hargreaves
3ad2c93448 Merge branch 'dev' of gitea:azpect/Potion into dev 2026-03-12 19:37:28 -07:00
Hayden Hargreaves
efeaccc6e3 fix: trying this on prod 2026-03-12 19:37:15 -07:00
f798ddb74c Merge pull request 'feature/orm' (#92) from feature/orm into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 57s
Reviewed-on: #92
2026-02-07 23:44:42 -07:00
9 changed files with 178 additions and 165 deletions

View File

@ -20,8 +20,13 @@
go
gopls
go-tools
htmx-lsp2
templ
tailwindcss_4
tailwindcss-language-server
watchman
docker-language-server
dockerfile-language-server-nodejs
gcc_multi
glibc_multi
nodejs

View File

@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
@ -32,12 +31,13 @@ func (s *Server) GoogleCallbackHandlerV2(ctx *gin.Context) {
domain := s.deps.EnvironmentConfig.FrontendDomain
if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
url := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
ctx.Redirect(http.StatusSeeOther, url)
redirectUrl := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
ctx.Redirect(http.StatusSeeOther, redirectUrl)
} else {
url := fmt.Sprintf("%s/v2/web/home", domain)
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
ctx.Redirect(http.StatusSeeOther, url)
// Pass JWT via query param - frontend will set the cookie
// This bypasses cross-origin cookie issues with Cloudflare/proxies
redirectUrl := fmt.Sprintf("%s/v2/web/auth/callback?token=%s", domain, url.QueryEscape(jwt))
ctx.Redirect(http.StatusSeeOther, redirectUrl)
}
}

View File

@ -43,8 +43,8 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
value,
maxAge,
path,
".gophernest.net", // or your backend domain / parent
true, // secure
"gophernest.net",
true,
httpOnly,
)
case "dev":

View File

@ -210,41 +210,19 @@ func (r *RecipeRepository) DeleteRecipe(recipeId, userId int) error {
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
// and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select(
"id",
"title",
"description",
"instructions",
"serves",
"difficulty",
"duration",
"category",
"ingredients",
"userid",
"modified",
"created",
"deleted",
).
From("recipes").
Where(sq.Eq{
"id": id,
"deleted": false,
})
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to construct sql query: %w", err)
}
query := `SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created, deleted
FROM recipes
WHERE id = $1 AND deleted = false;
`
var durationBytes []byte
var instructions pq.StringArray
var ingredientBytes []byte
var recipe domain.Recipe
if err := r.db.QueryRowx(_sql, args...).Scan(
if err := r.db.QueryRow(query, id).Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
@ -526,64 +504,51 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// this function successfully, therefore, it must be an existing recipe. Any errors will be bubbled
// to the caller.
func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error {
tx, err := r.db.Beginx()
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
psql := sq.StatementBuilder.
PlaceholderFormat(sq.Dollar).
RunWith(tx)
// Normalize tags (lowercase, trimmed, no duplicates)
normalized := make(map[string]struct{})
// Normalize the tag names (lower case with trimmed space)
normalized := make(map[string]struct{}) // Use map to disallow duplicates
for _, tag := range tags {
t := strings.ToLower(strings.TrimSpace(tag))
if t != "" {
normalized[t] = struct{}{}
}
normalized[strings.ToLower(strings.TrimSpace(tag))] = struct{}{}
}
// Insert tags and collect IDs
var tagIDs []int
// Insert the tags into the DB and return their IDS into the tag ID list
var tagIds []int
for tag := range normalized {
var tagID int
var tagId int
_sql, args, err := psql.
Insert("tags").
Columns("name").
Values(tag).
Suffix("ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id").
ToSql()
query := `
INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
`
err := tx.QueryRow(query, tag).Scan(&tagId)
if err != nil {
return fmt.Errorf("failed to build tag insert query: %w", err)
return fmt.Errorf("Failed to retrieve or create tag. %s\n", err.Error())
}
if err = tx.QueryRowx(_sql, args...).Scan(&tagID); err != nil {
return fmt.Errorf("failed to retrieve or create tag: %w", err)
}
tagIDs = append(tagIDs, tagID)
tagIds = append(tagIds, tagId)
}
// Insert recipe <-> tag mappings
for _, tagID := range tagIDs {
_sql, args, err := psql.
Insert("RecipeTags").
Columns("RecipeId", "TagId").
Values(recipe.Id, tagID).
ToSql()
// Using a prepared statement, execute the mapping insertions one-by-one
for _, id := range tagIds {
stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);")
if err != nil {
return fmt.Errorf("failed to build recipe tag mapping query: %w", err)
return fmt.Errorf("Failed to create statement for recipe tag mapping. %s\n", err.Error())
}
defer stmt.Close()
if _, err = tx.Exec(_sql, args...); err != nil {
return fmt.Errorf("failed to insert recipe tag mapping: %w", err)
if _, err := stmt.Exec(recipe.Id, id); err != nil {
return fmt.Errorf("Failed to insert tag-recipe-mapping. %s\n", err.Error())
}
}
if err = tx.Commit(); err != nil {
if err := tx.Commit(); err != nil {
tx.Rollback()
return err
}
@ -595,88 +560,75 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
// and creates new associations for the provided tags. The recipe object must contain
// a valid ID. Any errors will be bubbled to the caller.
func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // Rollback if we don't commit
if recipe.Id <= 0 {
return fmt.Errorf("[ERROR] Recipe must have a valid ID")
}
tx, err := r.db.Beginx()
// Step 1: Delete all existing tag associations for this recipe
deleteQuery := `DELETE FROM RecipeTags WHERE RecipeId = $1;`
if _, err := tx.Exec(deleteQuery, recipe.Id); err != nil {
return fmt.Errorf("[ERROR] Failed to delete existing recipe tags: %w", err)
}
// Step 2: Normalize the tag names (lower case with trimmed space)
normalized := make(map[string]struct{}) // Use map to disallow duplicates
for _, tag := range tags {
trimmed := strings.ToLower(strings.TrimSpace(tag))
if trimmed != "" {
normalized[trimmed] = struct{}{}
}
}
// If no tags provided, we're done (all tags removed)
if len(normalized) == 0 {
if err := tx.Commit(); err != nil {
return err
}
return nil
}
// Step 3: Insert the tags into the DB and return their IDs into the tag ID list
var tagIds []int
for tag := range normalized {
var tagId int
query := `
INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
`
err := tx.QueryRow(query, tag).Scan(&tagId)
if err != nil {
return fmt.Errorf("[ERROR] Failed to retrieve or create tag: %w", err)
}
tagIds = append(tagIds, tagId)
}
// Step 4: Insert the new tag associations
// Use a single prepared statement for all inserts
stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);")
if err != nil {
return fmt.Errorf("[ERROR] Failed to create statement for recipe tag mapping: %w", err)
}
defer stmt.Close()
for _, id := range tagIds {
if _, err := stmt.Exec(recipe.Id, id); err != nil {
return fmt.Errorf("[ERROR] Failed to insert tag-recipe mapping: %w", err)
}
}
// Commit the transaction
if err := tx.Commit(); err != nil {
return err
}
defer tx.Rollback()
psql := sq.StatementBuilder.
PlaceholderFormat(sq.Dollar).
RunWith(tx)
// Step 1: delete existing tag mappings
{
_sql, args, err := psql.
Delete("RecipeTags").
Where(sq.Eq{"RecipeId": recipe.Id}).
ToSql()
if err != nil {
return fmt.Errorf("[ERROR] failed to build delete recipe tags query: %w", err)
}
if _, err = tx.Exec(_sql, args...); err != nil {
return fmt.Errorf("[ERROR] failed to delete existing recipe tags: %w", err)
}
}
// Step 2: normalize tags
normalized := make(map[string]struct{})
for _, tag := range tags {
t := strings.ToLower(strings.TrimSpace(tag))
if t != "" {
normalized[t] = struct{}{}
}
}
// No tags means "remove all tags" — were done
if len(normalized) == 0 {
return tx.Commit()
}
// Step 3: upsert tags and collect IDs
var tagIDs []int
for tag := range normalized {
var tagID int
_sql, args, err := psql.
Insert("tags").
Columns("name").
Values(tag).
Suffix("ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id").
ToSql()
if err != nil {
return fmt.Errorf("[ERROR] failed to build tag upsert query: %w", err)
}
if err = tx.QueryRowx(_sql, args...).Scan(&tagID); err != nil {
return fmt.Errorf("[ERROR] failed to retrieve or create tag: %w", err)
}
tagIDs = append(tagIDs, tagID)
}
// Step 4: insert new recipe ↔ tag mappings
for _, tagID := range tagIDs {
_sql, args, err := psql.
Insert("RecipeTags").
Columns("RecipeId", "TagId").
Values(recipe.Id, tagID).
ToSql()
if err != nil {
return fmt.Errorf("[ERROR] failed to build recipe tag mapping query: %w", err)
}
if _, err = tx.Exec(_sql, args...); err != nil {
return fmt.Errorf("[ERROR] failed to insert recipe tag mapping: %w", err)
}
}
return tx.Commit()
return nil
}
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
@ -757,21 +709,28 @@ func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error {
return nil
}
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("t.*").
From("tags t").
Join("recipetags rt on rt.tagid = t.id").
Where(sq.Eq{"rt.recipeid": recipe.Id})
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct sql query: %w", err)
}
recipe.Tags = []domain.Tag{}
if err := r.db.Select(&recipe.Tags, _sql, args...); err != nil {
return fmt.Errorf("Failed to get recipe tags: %w", err)
query := `
SELECT t.* FROM tags t
JOIN recipetags rt ON rt.tagid = t.id
WHERE rt.recipeid = $1;
`
rows, err := r.db.Query(query, recipe.Id)
if err != nil {
return fmt.Errorf("Failed to get tags for recipe. %s\n", err.Error())
}
defer rows.Close()
for rows.Next() {
var tag domain.Tag
err := rows.Scan(&tag.Id, &tag.Name, &tag.Created)
if err != nil {
return fmt.Errorf("Failed to scan tag onto domain model. %s\n", err.Error())
}
recipe.Tags = append(recipe.Tags, tag)
}
return nil

View File

@ -13,6 +13,7 @@ import { use, type ReactNode } from 'react';
import { AuthContext } from './context/AuthContext';
import RecipePage from './pages/Recipe';
import SearchPage from './pages/Search';
import AuthCallback from './pages/AuthCallback';
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isLoggedIn } = use(AuthContext)
@ -37,6 +38,9 @@ function App() {
{/* Login page does not inherit WebLayout */}
<Route path="/v2/web/login" element={<LoginPage />} />
{/* Auth callback - handles token from OAuth redirect */}
<Route path="/v2/web/auth/callback" element={<AuthCallback />} />
<Route path="/v2/web" element={<WebLayout />}>
<Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
<Route path="home" element={<Home />} />

View File

@ -4,6 +4,7 @@ import type { SearchFilters } from "../types/search";
interface FilterContextType {
filters: SearchFilters;
setFilters: (filters: SearchFilters) => void;
resetFilters: () => void;
}
export const FilterContext = createContext<FilterContextType>({
@ -16,4 +17,5 @@ export const FilterContext = createContext<FilterContextType>({
Favorites: false,
},
setFilters: () => { return },
resetFilters: () => { return },
});

View File

@ -36,7 +36,7 @@ export function FilterProvider({ children }: { children: ReactNode }) {
}, [filters]);
return (
<FilterContext value={{ filters, setFilters }}>
<FilterContext value={{ filters, setFilters, resetFilters: () => setFilters(DEFAULT_FILTERS) }}>
{children}
</FilterContext>
)

View File

@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useCookies } from "react-cookie";
export default function AuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [, setCookie] = useCookies(["jwt_token"]);
useEffect(() => {
const token = searchParams.get("token");
if (token) {
// Set cookie with 7 day expiration, accessible across all subdomains
setCookie("jwt_token", token, {
path: "/",
domain: "gophernest.net", // shared across all subdomains
maxAge: 60 * 60 * 24 * 7, // 7 days in seconds
secure: true,
sameSite: "lax",
});
void navigate("/v2/web/home", { replace: true });
} else {
// No token provided, redirect to login
void navigate("/v2/web/login", { replace: true });
}
}, [searchParams, setCookie, navigate]);
return null;
}

View File

@ -12,10 +12,13 @@ import { GetRecipeOfTheWeek } from "../services/RecipeService";
import { isApiError, type ApiError } from "../types/api/error";
import { AuthContext } from "../context/AuthContext";
import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService";
import { useNavigate } from "react-router-dom";
import { FilterContext } from "../context/FilterContext";
export default function Home() {
// Context
const { isLoggedIn } = use(AuthContext);
const { resetFilters } = use(FilterContext);
// Page state
const [recipeOfTheWeek, setRecipeOfTheWeek] = useState<Recipe | null>(null);
@ -24,6 +27,7 @@ export default function Home() {
const [viewedRecipes, setViewedRecipes] = useState<Recipe[]>([]);
const [error, setError] = useState<string>("");
const navigate = useNavigate();
// Fetch the recipe of the week
useEffect(() => {
@ -55,6 +59,12 @@ export default function Home() {
void fetch();
}, [isLoggedIn]);
const viewAllRecipesHandler = () => {
// Clear filters
resetFilters();
void navigate(ROUTE_CONSTANTS.Search);
}
// BUG: Prob remove
useEffect(() => {
if (error)
@ -90,8 +100,11 @@ export default function Home() {
</div>
<p className="leading-relaxed text-gray-800">
Not sure what you want? {" "}
<a href={ROUTE_CONSTANTS.Search} className="text-blue-500 underline hover:text-blue-700 duration-300">
<button onClick={viewAllRecipesHandler} className="text-blue-500 underline hover:text-blue-700 duration-300 cursor-pointer">
View all recipes here
</button>
<a href={ROUTE_CONSTANTS.Search} className="text-blue-500 underline hover:text-blue-700 duration-300">
</a>
</p>
</section>