Compare commits

...

No commits in common. "6cb0a921c96d8a0186879123422e63d8d6975455" and "602a13934999f024245aff183b76716b3a6733e5" have entirely different histories.

422 changed files with 30575 additions and 31723 deletions

11
.gitignore vendored
View file

@ -26,6 +26,8 @@ authorized_keys
# Platform / Auth Secrets # Platform / Auth Secrets
firebase-auth-*.json firebase-auth-*.json
*service-account*.json
go-backend/firebase-service-account.json
google-services.json google-services.json
GoogleService-Info.plist GoogleService-Info.plist
*credentials.json *credentials.json
@ -64,6 +66,8 @@ desktop.ini
*.tar.gz *.tar.gz
*.tar *.tar
*.gz *.gz
*.backup
*.orig
*.exe *.exe
*.bin *.bin
*.db *.db
@ -117,6 +121,9 @@ debug/
*.g.dart *.g.dart
*.freezed.dart *.freezed.dart
# Service account credentials
.env_files/
# Project Specific Exclusions # Project Specific Exclusions
logo.ai logo.ai
sojorn_app/analysis_results_final.txt sojorn_app/analysis_results_final.txt
@ -130,9 +137,13 @@ go-backend/verify*
go-backend/migrate* go-backend/migrate*
go-backend/fixdb* go-backend/fixdb*
go-backend/api.exe go-backend/api.exe
go-backend/scripts/run_migration.go
temp_server.env temp_server.env
*.txt.bak *.txt.bak
# Non-public staging area (kept local only)
_private/
# Miscellaneous Security # Miscellaneous Security
*.history *.history
*.bash_history *.bash_history

View file

@ -0,0 +1,5 @@
---
trigger: manual
---
ALWAYS EDIT FILES LOCALLY, SYNC TO THE GIT, THEN PULL ON THE SERVER - SSH MPLS - AND BUILD.

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

144
PRIVACY.md Normal file
View file

@ -0,0 +1,144 @@
# Sojorn — Privacy & Data Sovereignty
**Effective Date:** February 12, 2026
**Last Updated:** February 17, 2026
**Operator:** MPLS LLC
---
## Our Philosophy: Privacy as a Sanctuary
Profiting from surveillance is strictly against our principles. We reject the "attention economy" model entirely.
Most social platforms treat your data as their product. They harvest your posts, your photos, your location, your relationships, and your attention — then sell access to the highest bidder. We built Sojorn to prove that a social network can exist without any of that.
**Sojorn is a walled garden where your data is not a commodity.** We are groundskeepers of this space — not owners of what grows in it.
---
## 1. Data Sovereignty
We do not sell your data. We do not license your data. We do not provide your data to third-party analytics, advertising, or data brokerage firms. Your content is not indexed on public search engines. Sojorn is a private community designed to protect your posts and identity from the extractivist economy.
## 2. What We Collect
We collect only what is technically necessary to operate the Service:
| Data | Purpose | Retention |
|---|---|---|
| **Email address** | Authentication, critical account notifications | Until account deletion |
| **Birth month & year** | Age verification (16+ requirement) | Until account deletion |
| **Display name & handle** | Profile identity within the network | Until account deletion |
| **Content you create** | Posts, comments, images, video — displayed to your chosen audience | Until you delete it |
| **Approximate location** (Beacons only) | Community safety incident reporting | Ephemeral — not stored permanently |
| **Device push tokens** | Delivering notifications you have opted into | Until account deletion or token refresh |
We do **not** collect:
- Precise GPS location outside of Beacons
- Contact lists or phone books
- Browsing history outside of Sojorn
- Biometric data
- Financial information
## 3. Third-Party Services
| Service | Purpose | Data Shared |
|---|---|---|
| **Firebase** | Authentication, push notifications | Email, device token |
| **Cloudflare R2** | Media file storage (images, video) | Uploaded media files |
| **SendPulse** | Newsletter delivery (opt-in only) | Email address |
| **OpenAI / Google Vision** | Content moderation (hate speech, violence detection) | Text snippets and image URLs of public posts only |
We do **not** use third-party tracking pixels, cross-site cookies, behavioral analytics, or advertising SDKs.
### AI Moderation Disclosure
Public posts may be analyzed by AI moderation systems to detect policy violations (hate speech, violence, spam, NSFW content). This analysis:
- Is performed only on content you post publicly or within groups.
- Does **not** apply to end-to-end encrypted messages or capsule content.
- Does **not** train AI models on your content — we use pre-trained safety classifiers only.
- Is subject to human review before permanent moderation action.
- Produces an audit trail visible to administrators for accountability.
## 4. Zero-Knowledge Encryption
Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. Your encryption keys are wrapped with a passphrase only you know and stored as an opaque encrypted blob on our servers. **We cannot decrypt your private content.** We cannot comply with requests to produce content we cannot read.
## 5. Your Right to Vanish
You have the absolute right to delete your account and all associated data at any time.
When you delete content or your account, we perform **hard deletes**:
- Database records are permanently removed (not soft-deleted).
- Media files (images, video) are permanently removed from storage buckets.
- Encryption key backups are permanently removed.
- We do not retain shadow copies, hidden archives, or behavioral profiles.
When you leave, you leave.
## 6. Anti-Extraction Commitment
MPLS LLC will never:
- Use your content to train artificial intelligence or machine learning models.
- Sell, license, or share your content with data brokers or advertisers.
- Build advertising or behavioral profiles from your activity.
- Provide "data partnerships" or "audience insights" products derived from your content.
## 7. Right to Livelihood
If MPLS LLC ever wishes to feature your content in promotional materials outside of the Sojorn app interface, we must contact you directly, offer financial compensation, and receive your explicit written consent. See Section 4.5 of our [Terms of Service](https://sojorn.net/terms) for full details.
## 8. Anti-Scraping
We actively defend against unauthorized commercial harvesting of user content through rate limiting, authentication requirements, and automated abuse detection. Unauthorized scraping of Sojorn content is a violation of these Terms and may be pursued under the Computer Fraud and Abuse Act (CFAA).
## 9. Law Enforcement
We will comply with valid legal process (court orders, subpoenas) as required by law. However:
- We will notify affected users unless legally prohibited from doing so.
- We cannot produce end-to-end encrypted content (we do not have the keys).
- We will challenge overbroad or legally deficient requests.
- We will publish a transparency report annually documenting any government data requests received.
## 10. Children's Privacy
Sojorn is not intended for users under 16. We do not knowingly collect data from children. If we discover that a user is under 16, we will delete their account and all associated data.
## 11. International Users
Sojorn is operated by MPLS LLC from the United States. If you are accessing the Service from the European Union, your data is processed in the United States. We apply the same privacy protections to all users regardless of jurisdiction.
## 12. Changes to This Policy
We will notify registered users via email and in-app notification of any material changes to this Privacy Policy at least 30 days before they take effect.
## 13. Contact
For privacy concerns: [privacy@sojorn.net](mailto:privacy@sojorn.net)
For legal inquiries: [legal@mp.ls](mailto:legal@mp.ls)
---
## Why We Chose This Model
### Right Livelihood for Creators
Our source code is published under the [GNU Affero General Public License v3.0](./LICENSE). We share our code so that users, security researchers, and the public can verify that we honor every commitment in this document. We chose this license because it ensures that all modifications — including those running on network servers — remain open and available to the community.
We call this **Right Livelihood for Creators** — we share our work for your safety, and we protect user freedom so we can remain independent and never need to monetize your attention or your data.
### Privacy as a Sanctuary
Every technical decision we make is measured against a simple question: *Does this protect or erode the sanctuary?*
- We chose E2EE for private messages — because a sanctuary has walls.
- We chose hard deletes — because a sanctuary does not hoard what you discard.
- We chose AI moderation with human review — because a sanctuary has guardians, not surveillance cameras.
- We chose no advertising SDK — because a sanctuary is not a billboard.
**MPLS LLC — Groundskeepers, not owners.**

112
TERMS.md Normal file
View file

@ -0,0 +1,112 @@
# Sojorn — Terms of Service
**Effective Date:** February 12, 2026
**Last Updated:** February 17, 2026
**Operator:** MPLS LLC
---
## 1. The Agreement
By accessing Sojorn ("the Service"), you acknowledge that you are entering a space dedicated to respect, safety, and progressive action. These Terms of Service ("Terms") constitute a binding agreement between you and MPLS LLC ("we," "us," "our"). We prioritize the safety of our community above all else.
## 2. Zero Tolerance Policy
We do not tolerate intolerance. Hate speech, racism, sexism, homophobia, transphobia, ableism, and fascist ideologies are strictly prohibited. Violations will result in immediate and permanent account suspension.
## 3. No Misinformation
We reject the spread of verifiable falsehoods, conspiracy theories, and coordinated disinformation campaigns. Posting content designed to deceive or manipulate will result in account termination.
## 4. Content Ownership and Sanctuary
This section replaces the broad content licenses found in conventional social media Terms of Service. We believe your work belongs to you — always.
### 4.1 Ownership
**You retain 100% copyright and all intellectual property rights to every piece of content you create on Sojorn.** We claim no ownership over your words, images, audio, video, or any other creative work.
### 4.2 Limited Technical License
By posting content, you grant MPLS LLC a **non-exclusive, royalty-free, worldwide license solely for the technical purpose of hosting, displaying, and transmitting your content** to the audience you designate within Sojorn. This license exists only so that our servers can store and deliver your content as the software requires.
### 4.3 Immediate Revocation
This technical license **expires immediately and irrevocably upon deletion** of the content by you. When you delete a post, comment, image, or any other content, we will remove it from our active systems and storage buckets. We do not retain shadow copies, hidden archives, or "soft-deleted" records of your content.
### 4.4 Anti-Extraction Covenant
MPLS LLC **will never**:
- Use your content to train artificial intelligence or machine learning models.
- Sell, license, or provide your content to third-party data brokers, advertisers, or analytics firms.
- Mine your content for advertising profiles or behavioral targeting.
- Index your content on public search engines without your explicit opt-in.
### 4.5 Right to Livelihood
If MPLS LLC ever wishes to use your content for promotional purposes outside the Sojorn application interface — including but not limited to marketing materials, press releases, social media promotion, or investor presentations — we must:
1. **Contact you directly** with a clear written description of the intended use.
2. **Offer financial compensation** for that use.
3. **Receive your explicit, written opt-in consent** before any such use.
No blanket consent is granted by agreeing to these Terms. Each promotional use requires a separate agreement.
## 5. Your Right to Vanish
You have the absolute right to delete your account and all associated data at any time. When you leave, you leave. We perform hard deletes — your profile, posts, comments, media files, and metadata are permanently removed from our systems.
## 6. End-to-End Encryption
Private messages and encrypted capsule content are protected by end-to-end encryption (E2EE) using keys generated on your device. MPLS LLC has no ability to decrypt, read, or access this content. We cannot comply with requests to produce content we cannot read.
## 7. AI Moderation Transparency
We use artificial intelligence to assist with content moderation (detecting hate speech, violence, spam, and other policy violations). This AI moderation:
- Operates only on content you post publicly or within groups.
- Does not analyze private encrypted messages (we cannot).
- Is subject to human review — AI flags are reviewed by human moderators before permanent action is taken.
- Provides a full audit trail visible to administrators for accountability.
You may appeal any AI moderation decision through our in-app appeal process.
## 8. Community Safety Beacons
Sojorn includes a community safety feature ("Beacons") that allows users to report real-world safety incidents with location data. By using this feature, you consent to sharing approximate location data with other Sojorn users in your vicinity. Location data is not stored permanently and is not sold to third parties.
## 9. Age Requirement
You must be at least 16 years of age to use Sojorn. We collect birth month and year during registration solely to enforce this requirement.
## 10. Liability
MPLS LLC provides this Service "as is." We are not liable for interactions that occur between users, though we commit to active moderation to maintain community safety. We are not liable for the accuracy of community safety Beacons posted by users.
## 11. Governing Law
These Terms are governed by the laws of the State of Minnesota, United States.
## 12. Changes to These Terms
We will notify registered users via email and in-app notification of any material changes to these Terms at least 30 days before they take effect.
## 13. Open Source Licensing
Sojorn's source code is published under the [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html) (AGPL-3.0). This means:
- The complete source code for Sojorn is publicly available.
- Anyone may inspect, modify, and redistribute the code under the terms of the AGPL-3.0.
- Any modified version of Sojorn that is made available over a network must also make its complete source code available under the same license.
- This license ensures that all improvements to Sojorn — including those running on third-party servers — remain open and available to the community.
Our source code repository is hosted at [gitlab.com/patrickbritton3/sojorn](https://gitlab.com/patrickbritton3/sojorn).
## 14. Contact
For questions about these Terms: [legal@mp.ls](mailto:legal@mp.ls)
---
*MPLS LLC — Groundskeepers, not owners.*

View file

@ -1,174 +0,0 @@
# Beacon System Architecture
## Overview
Sojorn has **two separate posting systems** that share the same database but create slightly different content:
1. **Regular Posts** (compose_screen.dart) - Standard social media posts
2. **Beacon Posts** (create-beacon_sheet.dart) - GPS-tagged safety alerts
## How It Works
### Database Structure
Both systems create records in the `posts` table, but with different flags:
| Field | Regular Post | Beacon Post |
|-------|-------------|-------------|
| `is_beacon` | `FALSE` | `TRUE` |
| `beacon_type` | `NULL` | `'police'`, `'checkpoint'`, `'taskForce'`, `'hazard'`, `'safety'`, or `'community'` |
| `location` | `NULL` | GPS coordinates (PostGIS POINT) |
| `confidence_score` | `NULL` | 0.5 - 1.0 (starts at 50-80% based on user trust) |
| `is_active_beacon` | `NULL` | `TRUE` (becomes `FALSE` when pruned) |
| `allow_chain` | User choice | Always `FALSE` |
### User Opt-In System
**Critical Feature**: Beacon posts are **OPT-IN ONLY** for feeds.
#### `profiles.beacon_enabled` Column
- **Default**: `FALSE` (opted out)
- **When FALSE**: User NEVER sees beacon posts in their Following or Sojorn feeds
- **When TRUE**: User sees beacon posts mixed in with regular posts
- **Beacon Map**: ALWAYS visible regardless of this setting
#### Why Opt-In?
Some users don't want safety alerts mixed into their social feed. The opt-in system allows:
- **Casual users**: Just social content
- **Community safety advocates**: Social content + beacons
- **Everyone**: Can still view the Beacon Network map anytime
### Feed Filtering Logic
#### feed-personal (Following Feed)
```typescript
// Check user's beacon preference
const { data: profile } = await supabase
.from("profiles")
.select("beacon_enabled")
.eq("id", user.id)
.single();
const beaconEnabled = profile?.beacon_enabled || false;
// Build query
let postsQuery = supabase.from("posts").select(...);
// Filter out beacons if user has NOT opted in
if (!beaconEnabled) {
postsQuery = postsQuery.eq("is_beacon", false);
}
```
#### feed-sojorn (Algorithmic Feed)
Same logic - beacons are filtered unless `beacon_enabled = TRUE`.
### Beacon Creation Flow
1. User opens Beacon Network tab
2. Taps map to drop beacon
3. Fills out CreateBeaconSheet:
- Type (police, checkpoint, etc.)
- Title
- Description
- Optional photo
4. Submits → Edge function `create-beacon`
5. **Creates a POST in the `posts` table** with:
- `is_beacon = TRUE`
- `beacon_type = <selected_type>`
- `location = GPS point`
- `category_id = "Beacon Alerts"` category
- `confidence_score` based on user's trust score
- `allow_chain = FALSE`
### Regular Post Creation Flow
1. User taps "New Post" button
2. Fills out ComposeScreen:
- Community selection
- Body text
- Optional photo
- Toggle for chain responses
3. Submits → Edge function `publish-post`
4. **Creates a POST in the `posts` table** with:
- `is_beacon = FALSE`
- No GPS data
- User-selected category
- User's chain preference
## Key Differences
| Feature | Regular Post | Beacon Post |
|---------|-------------|-------------|
| **Purpose** | Social sharing | Safety alerts |
| **GPS Data** | No | Required |
| **Visible On** | Feeds (if user follows author) | Beacon map + feeds (if user opted in) |
| **Category** | User selects | Always "Beacon Alerts" |
| **Chaining** | User choice | Disabled |
| **Confidence Score** | No | Yes (trust-based) |
| **Voting** | No | Yes (vouch/report) |
| **Auto-Pruning** | No | Yes (low confidence + old = disabled) |
## User Experience Scenarios
### Scenario 1: User With Beacons Disabled (Default)
```
Following Feed: ✓ Regular posts from people they follow
Sojorn Feed: ✓ Algorithmic regular posts
Beacon Map: ✓ All active beacons in area
```
### Scenario 2: User With Beacons Enabled
```
Following Feed: ✓ Regular posts + beacons from people they follow
Sojorn Feed: ✓ Algorithmic regular posts + beacons
Beacon Map: ✓ All active beacons in area
```
### Scenario 3: User Creates Beacon
1. Beacon appears on map IMMEDIATELY for ALL users
2. Beacon appears in creator's feed (if they have beacons enabled)
3. Beacon appears in OTHER users' feeds (if they follow creator AND have beacons enabled)
## Migration Required
To enable this system, run:
```sql
-- Add beacon_enabled column to profiles
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE;
-- Add index for fast filtering
CREATE INDEX IF NOT EXISTS idx_profiles_beacon_enabled ON profiles(beacon_enabled) WHERE beacon_enabled = TRUE;
```
Or apply the migration file:
```bash
# Via Supabase Dashboard SQL Editor
# Paste contents of: supabase/migrations/add_beacon_opt_in.sql
```
## Edge Functions Updated
1. ✅ **feed-personal** - Now filters beacons based on user preference
2. ✅ **feed-sojorn** - Now filters beacons based on user preference
3. ✅ **create-beacon** - Creates beacon posts correctly
4. ✅ **publish-post** - Creates regular posts correctly
## Frontend Components
- ✅ **ComposeScreen** - Regular post composer
- ✅ **CreateBeaconSheet** - Beacon post composer
- 🔲 **Settings Screen** - TODO: Add toggle for `beacon_enabled` preference
- ✅ **BeaconScreen** - Shows beacons on map (always visible)
- ✅ **FeedPersonalScreen** - Filtered feed
- ✅ **FeedSojornScreen** - Filtered feed
## Next Steps
1. Apply database migration (`add_beacon_opt_in.sql`)
2. Deploy updated edge functions
3. Add UI toggle in user settings for beacon opt-in
4. Test both posting flows
5. Verify feed filtering works correctly

View file

@ -1,61 +0,0 @@
# Create Search Tags View
The search function requires a database view called `view_searchable_tags` for efficient tag searching.
## Why This Is Needed
Without this view, the search function would need to download ALL posts from the database just to count tags, which will:
- Timeout with 1000+ posts
- Crash the Edge Function
- Cause poor performance
The view pre-aggregates tag counts at the database level, making searches instant.
## How to Create the View
### Option 1: Via Supabase Dashboard (Recommended)
1. Go to your Supabase project's SQL Editor:
https://supabase.com/dashboard/project/zwkihedetedlatyvplyz/sql
2. Paste and run this SQL:
```sql
CREATE OR REPLACE VIEW view_searchable_tags AS
SELECT
unnest(tags) as tag,
COUNT(*) as count
FROM posts
WHERE
deleted_at IS NULL
AND tags IS NOT NULL
AND array_length(tags, 1) > 0
GROUP BY unnest(tags)
ORDER BY count DESC;
```
3. Click "RUN" to execute
### Option 2: Via PowerShell (if you have psql installed)
Run this from the project root:
```powershell
Get-Content supabase\migrations\create_searchable_tags_view.sql | psql $DATABASE_URL
```
Replace `$DATABASE_URL` with your Supabase database connection string.
## Verifying It Works
After creating the view, test it with:
```sql
SELECT * FROM view_searchable_tags LIMIT 10;
```
You should see a list of tags with their counts.
## What Happens If You Don't Create It?
The search function will return an error when searching for tags. Users and posts will still work fine, but tag search will fail until this view is created.

View file

@ -1,70 +0,0 @@
# Database Migration: Enhanced Search Function
This migration updates the `search_sojorn()` function to enable full-text search across posts, users, and hashtags.
## What Changed
The search function now searches:
- **Users**: by handle AND display name (previously only handle)
- **Tags**: hashtags from the posts.tags array (unchanged)
- **Posts**: NEW - searches post body content for any word, matching hashtags
## Option 1: Apply via Supabase Dashboard (Recommended)
1. Go to your Supabase Dashboard: https://app.supabase.com/project/zwkihedetedlatyvplyz
2. Navigate to **SQL Editor** in the left sidebar
3. Click **"New Query"**
4. Copy and paste the SQL from `supabase/migrations/update_search_function.sql`
5. Click **"Run"** to execute the migration
6. Verify success - you should see "Success. No rows returned"
## Option 2: Apply via Supabase CLI
If you have Supabase CLI configured with your project:
```bash
# Link to your project (if not already linked)
supabase link --project-ref zwkihedetedlatyvplyz
# Push the migration
supabase db push --include-all
```
## Verification
After applying the migration, test the search:
1. Open the app and navigate to Search
2. Try searching for:
- A username (e.g., "john")
- A hashtag (e.g., "#nature")
- Any word from a post body (e.g., "wellness")
3. Click on a hashtag in a post - it should navigate to search with results
## Rollback (if needed)
If you need to revert, run this SQL:
```sql
CREATE OR REPLACE FUNCTION search_sojorn(p_query TEXT, limit_count INTEGER DEFAULT 10)
RETURNS JSON LANGUAGE plpgsql STABLE AS $$
DECLARE result JSON;
BEGIN
SELECT json_build_object(
'users', (SELECT json_agg(json_build_object('id', p.id, 'username', p.handle, 'display_name', p.display_name, 'avatar_url', p.avatar_url, 'harmony_tier', COALESCE(ts.tier, 'new')))
FROM profiles p LEFT JOIN trust_state ts ON p.id = ts.user_id WHERE p.handle ILIKE '%' || p_query || '%' LIMIT limit_count),
'tags', (SELECT json_agg(json_build_object('tag', tag, 'count', cnt)) FROM (
SELECT LOWER(UNNEST(tags)) AS tag, COUNT(*) AS cnt FROM posts WHERE tags IS NOT NULL AND deleted_at IS NULL
GROUP BY tag HAVING LOWER(tag) LIKE '%' || LOWER(p_query) || '%' ORDER BY cnt DESC LIMIT limit_count) t)
) INTO result;
RETURN result;
END;
$$;
```
Note: This removes the posts search capability.

View file

@ -1,76 +0,0 @@
# PowerShell script to apply database migration via Supabase CLI
# This connects to your remote Supabase project and applies the search function update
$PROJECT_REF = "zwkihedetedlatyvplyz"
$MIGRATION_FILE = "migrations/update_search_function.sql"
Write-Host "=====================================" -ForegroundColor Cyan
Write-Host "Sojorn Database Migration" -ForegroundColor Cyan
Write-Host "=====================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "This will update the search_sojorn() function to enable:" -ForegroundColor Yellow
Write-Host " - Full-text search in post bodies" -ForegroundColor White
Write-Host " - User search by display name AND handle" -ForegroundColor White
Write-Host " - Hashtag search with post results" -ForegroundColor White
Write-Host ""
# Check if Supabase CLI is installed
if (-not (Get-Command supabase -ErrorAction SilentlyContinue)) {
Write-Host "ERROR: Supabase CLI is not installed or not in PATH" -ForegroundColor Red
Write-Host "Install via: scoop install supabase" -ForegroundColor Yellow
Write-Host "Or visit: https://supabase.com/docs/guides/cli" -ForegroundColor Yellow
exit 1
}
Write-Host "Step 1: Linking to Supabase project..." -ForegroundColor Green
Write-Host "Project Reference: $PROJECT_REF" -ForegroundColor White
# Link to project (will prompt for database password if needed)
$linkResult = supabase link --project-ref $PROJECT_REF 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "ERROR: Failed to link to Supabase project" -ForegroundColor Red
Write-Host "Please ensure you have the correct project reference and database password" -ForegroundColor Yellow
Write-Host ""
Write-Host "Alternative: Apply via Supabase Dashboard" -ForegroundColor Cyan
Write-Host "1. Go to: https://app.supabase.com/project/$PROJECT_REF/sql" -ForegroundColor White
Write-Host "2. Copy contents of: $MIGRATION_FILE" -ForegroundColor White
Write-Host "3. Paste and run in SQL Editor" -ForegroundColor White
exit 1
}
Write-Host ""
Write-Host "Step 2: Applying migration..." -ForegroundColor Green
Write-Host "Reading: $MIGRATION_FILE" -ForegroundColor White
# Read the migration SQL
if (-not (Test-Path $MIGRATION_FILE)) {
Write-Host "ERROR: Migration file not found: $MIGRATION_FILE" -ForegroundColor Red
exit 1
}
$sql = Get-Content $MIGRATION_FILE -Raw
# Apply the migration
Write-Host "Executing SQL..." -ForegroundColor White
$result = $sql | supabase db execute 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "SUCCESS! Migration applied successfully" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host "1. Test the search functionality in your app" -ForegroundColor White
Write-Host "2. Search for users, hashtags, and words in posts" -ForegroundColor White
Write-Host "3. Click hashtags in posts to navigate to search" -ForegroundColor White
} else {
Write-Host ""
Write-Host "ERROR: Migration failed" -ForegroundColor Red
Write-Host "Error output:" -ForegroundColor Yellow
Write-Host $result
Write-Host ""
Write-Host "Try applying manually via Supabase Dashboard:" -ForegroundColor Cyan
Write-Host "https://app.supabase.com/project/$PROJECT_REF/sql" -ForegroundColor White
exit 1
}

View file

@ -5,7 +5,7 @@ echo "=== Sojorn Admin Panel Server Deployment ==="
# 1. Run DB migration # 1. Run DB migration
echo "--- Running DB migration ---" echo "--- Running DB migration ---"
export PGPASSWORD='A24Zr7AEoch4eO0N' export PGPASSWORD="${PGPASSWORD:?Set PGPASSWORD before running this script}"
psql -U postgres -h localhost -d sojorn <<'EOSQL' psql -U postgres -h localhost -d sojorn <<'EOSQL'
-- Algorithm configuration table -- Algorithm configuration table
@ -107,8 +107,8 @@ echo "--- DB migration complete ---"
echo "--- Checking Node.js ---" echo "--- Checking Node.js ---"
if ! command -v node &> /dev/null; then if ! command -v node &> /dev/null; then
echo "Installing Node.js 20..." echo "Installing Node.js 20..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -S bash - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
echo 'P22k154ever!' | sudo -S apt-get install -y nodejs sudo apt-get install -y nodejs
fi fi
echo "Node: $(node --version), npm: $(npm --version)" echo "Node: $(node --version), npm: $(npm --version)"
@ -125,9 +125,9 @@ echo "Go backend built successfully"
# 5. Restart Go backend # 5. Restart Go backend
echo "--- Restarting Go backend ---" echo "--- Restarting Go backend ---"
echo 'P22k154ever!' | sudo -S systemctl restart sojorn-api sudo systemctl restart sojorn-api
sleep 3 sleep 3
echo 'P22k154ever!' | sudo -S systemctl status sojorn-api --no-pager || true sudo systemctl status sojorn-api --no-pager || true
# 6. Setup admin frontend # 6. Setup admin frontend
echo "--- Setting up admin frontend ---" echo "--- Setting up admin frontend ---"
@ -152,7 +152,7 @@ NEXT_PUBLIC_API_URL=https://api.sojorn.net
EOF EOF
# 8. Create systemd service for admin # 8. Create systemd service for admin
echo 'P22k154ever!' | sudo -S tee /etc/systemd/system/sojorn-admin.service > /dev/null <<'EOF' sudo tee /etc/systemd/system/sojorn-admin.service > /dev/null <<'EOF'
[Unit] [Unit]
Description=Sojorn Admin Panel Description=Sojorn Admin Panel
After=network.target sojorn-api.service After=network.target sojorn-api.service
@ -172,15 +172,15 @@ EnvironmentFile=/opt/sojorn/admin/.env.local
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
echo 'P22k154ever!' | sudo -S systemctl daemon-reload sudo systemctl daemon-reload
echo 'P22k154ever!' | sudo -S systemctl enable sojorn-admin sudo systemctl enable sojorn-admin
echo 'P22k154ever!' | sudo -S systemctl restart sojorn-admin sudo systemctl restart sojorn-admin
sleep 3 sleep 3
echo 'P22k154ever!' | sudo -S systemctl status sojorn-admin --no-pager || true sudo systemctl status sojorn-admin --no-pager || true
# 9. Setup Nginx # 9. Setup Nginx
echo "--- Setting up Nginx for admin ---" echo "--- Setting up Nginx for admin ---"
echo 'P22k154ever!' | sudo -S tee /etc/nginx/sites-available/sojorn-admin > /dev/null <<'EOF' sudo tee /etc/nginx/sites-available/sojorn-admin > /dev/null <<'EOF'
server { server {
listen 80; listen 80;
server_name admin.sojorn.net; server_name admin.sojorn.net;
@ -201,11 +201,11 @@ EOF
# Enable site if not already # Enable site if not already
if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then
echo 'P22k154ever!' | sudo -S ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/
fi fi
echo 'P22k154ever!' | sudo -S nginx -t sudo nginx -t
echo 'P22k154ever!' | sudo -S systemctl reload nginx sudo systemctl reload nginx
echo "=== Deployment complete! ===" echo "=== Deployment complete! ==="
echo "Admin panel running on port 3001" echo "Admin panel running on port 3001"
@ -214,4 +214,4 @@ echo ""
echo "NEXT STEPS:" echo "NEXT STEPS:"
echo "1. Point admin.sojorn.net DNS A record to this server IP" echo "1. Point admin.sojorn.net DNS A record to this server IP"
echo "2. Run: sudo certbot --nginx -d admin.sojorn.net" echo "2. Run: sudo certbot --nginx -d admin.sojorn.net"
echo "3. Set an admin user: PGPASSWORD='A24Zr7AEoch4eO0N' psql -U postgres -h localhost -d sojorn -c \"UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';\"" echo "3. Set an admin user: psql -U postgres -h localhost -d sojorn -c \"UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';\""

View file

@ -1,542 +0,0 @@
# Sojorn Admin Panel — Comprehensive System Documentation
> Last updated: February 6, 2026
---
## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Authentication & Security](#authentication--security)
4. [Server Deployment](#server-deployment)
5. [Frontend (Next.js)](#frontend-nextjs)
6. [Backend API Routes](#backend-api-routes)
7. [Database Schema](#database-schema)
8. [Feature Reference](#feature-reference)
9. [Environment Variables](#environment-variables)
10. [Troubleshooting](#troubleshooting)
---
## Overview
The Sojorn Admin Panel is an internal tool for platform administrators to manage users, moderate content, review appeals, configure the feed algorithm, and monitor system health. It is a standalone Next.js 14 application that communicates with the existing Sojorn Go backend via a dedicated set of admin API endpoints.
**Key characteristics:**
- Separate frontend deployment from the main Flutter app
- Role-based access — only users with `role = 'admin'` in the `profiles` table can log in
- All admin actions are logged to the `audit_log` table
- Invisible Cloudflare Turnstile bot protection on login
- JWT authentication with 24-hour token expiry
---
## Architecture
```
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ Browser │ ◄────────────► │ Nginx │
│ admin.sojorn.net │ │ (reverse proxy) │
└─────────────────────┘ └──────┬───────────────┘
┌──────────────────┼──────────────────┐
│ port 3002 │ port 8080 │
▼ ▼ │
┌─────────────────┐ ┌─────────────────┐ │
│ Next.js 14 │ │ Go Backend │ │
│ (sojorn-admin) │ │ (sojorn-api) │ │
│ SSR + Static │ │ Gin framework │ │
└─────────────────┘ └────────┬────────┘ │
│ │
┌────────▼────────┐ │
│ PostgreSQL │ │
│ sojorn database │ │
└─────────────────┘ │
┌─────────────────┐ │
│ Cloudflare R2 │───────┘
│ (media storage) │
└─────────────────┘
```
### Tech Stack
| Component | Technology |
|-----------|-----------|
| Frontend | Next.js 14, TypeScript, TailwindCSS, Recharts, Lucide icons |
| Backend | Go 1.21+, Gin framework, pgx (PostgreSQL driver) |
| Database | PostgreSQL 15+ |
| Process management | systemd |
| Reverse proxy | Nginx |
| Bot protection | Cloudflare Turnstile (invisible mode) |
| Auth | JWT (HS256), bcrypt password hashing |
---
## Authentication & Security
### Login Flow
1. User enters email + password on `/login`
2. Cloudflare Turnstile invisible widget generates a token in the background
3. Frontend sends `POST /api/v1/admin/login` with `{ email, password, turnstile_token }`
4. Backend verifies Turnstile token with Cloudflare API
5. Backend checks `users` table for valid credentials (bcrypt)
6. Backend verifies account status is `active`
7. Backend checks `profiles.role = 'admin'`
8. Returns JWT (`access_token`) with 24-hour expiry + user profile data
9. Token stored in `localStorage` as `admin_token`
### Middleware Chain (Protected Routes)
All `/api/v1/admin/*` routes (except `/login`) pass through:
1. **AuthMiddleware** — Validates JWT, extracts `user_id` from `sub` claim, sets it in Gin context
2. **AdminMiddleware** — Queries `profiles.role` for the authenticated user, rejects non-admin users with 403
### Security Measures
- **Invisible Turnstile** — Blocks automated login attacks without user friction
- **Graceful degradation** — If `TURNSTILE_SECRET` is empty, verification is skipped (dev mode)
- **bcrypt** — Passwords hashed with bcrypt (default cost)
- **JWT expiry** — Admin tokens expire after 24 hours (vs 7 days for regular users)
- **Auto-logout** — Frontend redirects to `/login` on any 401 response
- **Audit logging** — Status changes, post deletions, and moderation actions are logged
### Granting Admin Access
```sql
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
```
Valid roles: `user`, `moderator`, `admin`
---
## Server Deployment
### Services
| Service | systemd unit | Port | Binary/Entry |
|---------|-------------|------|-------------|
| Go API | `sojorn-api` | 8080 | `/opt/sojorn/bin/api` |
| Admin Frontend | `sojorn-admin` | 3002 | `node .../next start --port 3002` |
### File Locations on Server
```
/opt/sojorn/
├── .env # Shared environment variables
├── bin/
│ └── api # Compiled Go binary
├── go-backend/ # Go source code
│ ├── cmd/api/main.go
│ ├── internal/
│ │ ├── handlers/admin_handler.go
│ │ ├── middleware/admin.go
│ │ └── ...
│ └── .env # Symlink or copy of /opt/sojorn/.env
├── admin/ # Next.js admin frontend
│ ├── .env.local # Frontend env vars
│ ├── .next/ # Build output
│ ├── node_modules/
│ ├── src/
│ │ ├── app/ # Page routes
│ │ └── lib/ # API client, auth context
│ └── package.json
└── firebase-service-account.json
```
### systemd Service Files
**`/etc/systemd/system/sojorn-admin.service`**:
```ini
[Unit]
Description=Sojorn Admin Panel
After=network.target
[Service]
Type=simple
User=patrick
Group=patrick
WorkingDirectory=/opt/sojorn/admin
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3002
Restart=on-failure
RestartSec=30
StartLimitIntervalSec=120
StartLimitBurst=3
Environment=NODE_ENV=production
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net
[Install]
WantedBy=multi-user.target
```
### Nginx Configuration
**`/etc/nginx/sites-available/sojorn-admin`**:
```nginx
server {
listen 80;
server_name admin.sojorn.net;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
### Common Operations
```bash
# Rebuild and deploy Go backend
cd /opt/sojorn/go-backend
go build -ldflags='-s -w' -o /opt/sojorn/bin/api ./cmd/api/main.go
sudo systemctl restart sojorn-api
# Rebuild and deploy admin frontend
cd /opt/sojorn/admin
npx next build
sudo systemctl restart sojorn-admin
# View logs
sudo journalctl -u sojorn-admin -f
sudo journalctl -u sojorn-api -f
# Check service status
sudo systemctl status sojorn-admin
sudo systemctl status sojorn-api
# SSL certificate (after DNS A record is pointed)
sudo certbot --nginx -d admin.sojorn.net
```
---
## Frontend (Next.js)
### Pages
| Route | File | Description |
|-------|------|-------------|
| `/` | `app/page.tsx` | Dashboard with stats cards and growth charts |
| `/login` | `app/login/page.tsx` | Admin login with invisible Turnstile |
| `/users` | `app/users/page.tsx` | User list with search, filter by status/role |
| `/users/[id]` | `app/users/[id]/page.tsx` | User detail — profile, stats, admin actions |
| `/posts` | `app/posts/page.tsx` | Post list with search, filter by status |
| `/posts/[id]` | `app/posts/[id]/page.tsx` | Post detail — content, media, moderation flags, admin actions |
| `/moderation` | `app/moderation/page.tsx` | Moderation queue — AI-flagged content pending review |
| `/appeals` | `app/appeals/page.tsx` | User appeals against violations |
| `/reports` | `app/reports/page.tsx` | User-submitted reports |
| `/algorithm` | `app/algorithm/page.tsx` | Feed algorithm & moderation threshold tuning |
| `/categories` | `app/categories/page.tsx` | Category management — create, edit, toggle sensitive |
| `/system` | `app/system/page.tsx` | System health, DB stats, audit log |
| `/settings` | `app/settings/page.tsx` | Admin session info, API URL override |
### Key Libraries
| Library | Source |
|---------|--------|
| `api.ts` | `src/lib/api.ts` — Singleton API client with JWT token management |
| `auth.tsx` | `src/lib/auth.tsx` — React context provider for auth state |
### API Client (`src/lib/api.ts`)
The `ApiClient` class provides typed methods for every admin API endpoint. Key behaviors:
- **Token management** — Stored in `localStorage` as `admin_token`, attached as `Bearer` header
- **Auto-logout** — Any 401 response clears token and redirects to `/login`
- **Base URL** — Configurable via `NEXT_PUBLIC_API_URL` environment variable
- **Error handling** — Throws `Error` with server-provided error message
### Auth Context (`src/lib/auth.tsx`)
- Wraps app in `AuthProvider`
- On mount, validates existing token by calling `getDashboardStats()`
- Provides `login()`, `logout()`, `isAuthenticated`, `isLoading`, `user`
---
## Backend API Routes
All routes prefixed with `/api/v1/admin/`.
### Authentication (no middleware)
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `POST` | `/login` | `AdminLogin` | Email/password login with Turnstile verification |
### Dashboard (requires auth + admin)
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/dashboard` | `GetDashboardStats` | User/post/moderation/appeal/report counts |
| `GET` | `/growth?days=30` | `GetGrowthStats` | Daily user & post creation counts for charts |
### User Management
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/users?limit=50&offset=0&search=&status=&role=` | `ListUsers` | Paginated user list with filters |
| `GET` | `/users/:id` | `GetUser` | Full user profile with follower/post/violation counts |
| `PATCH` | `/users/:id/status` | `UpdateUserStatus` | Set status: `active`, `suspended`, `banned`, `deactivated` |
| `PATCH` | `/users/:id/role` | `UpdateUserRole` | Set role: `user`, `moderator`, `admin` |
| `PATCH` | `/users/:id/verification` | `UpdateUserVerification` | Toggle `is_official` and `is_verified` flags |
| `POST` | `/users/:id/reset-strikes` | `ResetUserStrikes` | Reset violation strike counter to 0 |
### Post Management
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/posts?limit=50&offset=0&search=&status=&author_id=` | `ListPosts` | Paginated post list with filters |
| `GET` | `/posts/:id` | `GetPost` | Full post detail with moderation flags |
| `PATCH` | `/posts/:id/status` | `UpdatePostStatus` | Set status: `active`, `flagged`, `removed` |
| `DELETE` | `/posts/:id` | `DeletePost` | Soft-delete (sets `deleted_at` + status `removed`) |
### Moderation Queue
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/moderation?limit=50&offset=0&status=pending` | `GetModerationQueue` | AI-flagged content awaiting review |
| `PATCH` | `/moderation/:id/review` | `ReviewModerationFlag` | Actions: `approve`, `dismiss`, `remove_content`, `ban_user` |
### Appeals
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/appeals?limit=50&offset=0&status=pending` | `ListAppeals` | User appeals with violation details |
| `PATCH` | `/appeals/:id/review` | `ReviewAppeal` | Decision: `approved` or `rejected`, optional content restore |
### Reports
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/reports?limit=50&offset=0&status=pending` | `ListReports` | User-submitted reports |
| `PATCH` | `/reports/:id` | `UpdateReportStatus` | Set status: `reviewed`, `dismissed`, `actioned` |
### Algorithm & Feed Config
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/algorithm` | `GetAlgorithmConfig` | All key-value config pairs |
| `PUT` | `/algorithm` | `UpdateAlgorithmConfig` | Upsert a config `{ key, value }` |
### Categories
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/categories` | `ListCategories` | All content categories |
| `POST` | `/categories` | `CreateCategory` | Create `{ slug, name, description?, is_sensitive? }` |
| `PATCH` | `/categories/:id` | `UpdateCategory` | Update name, description, or sensitive flag |
### System
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| `GET` | `/health` | `GetSystemHealth` | DB ping, latency, connection pool stats, DB size |
| `GET` | `/audit-log?limit=50&offset=0` | `GetAuditLog` | Admin action history with actor handles |
---
## Database Schema
### Tables Created by Admin Migration
**`algorithm_config`** — Key-value store for feed and moderation tuning:
| Column | Type | Description |
|--------|------|-------------|
| `key` | `TEXT PRIMARY KEY` | Config identifier |
| `value` | `TEXT NOT NULL` | Config value |
| `description` | `TEXT` | Human-readable description |
| `updated_at` | `TIMESTAMPTZ` | Last modification time |
Default seed values:
| Key | Default | Description |
|-----|---------|-------------|
| `feed_recency_weight` | `0.4` | Weight for post recency in feed ranking |
| `feed_engagement_weight` | `0.3` | Weight for engagement metrics |
| `feed_harmony_weight` | `0.2` | Weight for author harmony/trust score |
| `feed_diversity_weight` | `0.1` | Weight for content diversity |
| `moderation_auto_flag_threshold` | `0.7` | AI score threshold for auto-flagging |
| `moderation_auto_remove_threshold` | `0.95` | AI score threshold for auto-removal |
| `moderation_greed_keyword_threshold` | `0.7` | Spam/greed detection threshold |
| `feed_max_posts_per_author` | `3` | Max posts from same author per feed page |
| `feed_boost_mutual_follow` | `1.5` | Boost multiplier for mutual follows |
| `feed_beacon_boost` | `1.2` | Boost multiplier for beacon posts |
**`audit_log`** — Admin action history:
| Column | Type | Description |
|--------|------|-------------|
| `id` | `UUID PRIMARY KEY` | Unique entry ID |
| `actor_id` | `UUID` | Admin who performed the action |
| `action` | `TEXT NOT NULL` | Action type (e.g., `post_status_change`, `admin_delete_post`) |
| `target_type` | `TEXT NOT NULL` | Entity type: `user`, `post`, `comment`, `appeal`, `report`, `config` |
| `target_id` | `UUID` | ID of the affected entity |
| `details` | `TEXT` | JSON string with action-specific metadata |
| `created_at` | `TIMESTAMPTZ` | Timestamp |
### Columns Ensured by Migration
The migration ensures these columns exist (added if missing):
| Table | Column | Type | Default |
|-------|--------|------|---------|
| `profiles` | `role` | `TEXT` | `'user'` |
| `profiles` | `is_verified` | `BOOLEAN` | `FALSE` |
| `profiles` | `is_private` | `BOOLEAN` | `FALSE` |
| `users` | `status` | `TEXT` | `'active'` |
| `users` | `last_login` | `TIMESTAMPTZ` | `NULL` |
### Pre-existing Tables Used by Admin
| Table | Admin Usage |
|-------|-------------|
| `users` | Login validation, status management, growth stats |
| `profiles` | Role checks, user details, verification flags |
| `posts` | Content listing, status changes, deletion |
| `comments` | Moderation flag targets, appeal content restoration |
| `moderation_flags` | Moderation queue — AI-generated flags with scores |
| `user_violations` | Violation records linked to moderation flags |
| `user_appeals` | Appeal records linked to violations |
| `user_status_history` | Log of admin-initiated status changes |
| `reports` | User-submitted reports of other users/content |
| `categories` | Content categories managed by admins |
| `follows` | Follower/following counts on user detail page |
---
## Feature Reference
### Dashboard
Displays real-time aggregate stats:
- **Users**: total, active, suspended, banned, new today
- **Posts**: total, active, flagged, removed, new today
- **Moderation**: pending flags, reviewed flags
- **Appeals**: pending, approved, rejected
- **Reports**: pending count
- **Growth charts**: Daily user & post registrations (configurable 7/30/90 day window)
### Moderation Workflow
1. **AI flagging** — Posts/comments are automatically analyzed by the `ModerationService` using OpenAI + Google Vision
2. **Three Poisons Score** — Content is scored on Hate, Greed, Delusion dimensions
3. **Auto-flag** — Content exceeding `moderation_auto_flag_threshold` is flagged for review
4. **Admin review** — Admin sees flagged content in the moderation queue with scores
5. **Actions available**:
- **Approve** — Content is fine, dismiss the flag
- **Dismiss** — Same as approve (flag was a false positive)
- **Remove content** — Soft-delete the post/comment
- **Ban user** — Ban the author and action the flag
### Appeal Workflow
1. User receives a violation (triggered by moderation)
2. User submits an appeal with reason and context
3. Appeal appears in admin panel with violation details, AI scores, and original content
4. Admin reviews and decides:
- **Approve** — Optionally restore the removed content
- **Reject** — Violation stands, include written reasoning (min 5 chars)
### User Management Actions
- **Change status**: `active``suspended``banned``deactivated` (requires reason)
- **Change role**: `user``moderator``admin`
- **Toggle verification**: `is_official` and `is_verified` badges
- **Reset strikes**: Clear the violation counter
---
## Environment Variables
### Frontend (`/opt/sojorn/admin/.env.local`)
| Variable | Value | Description |
|----------|-------|-------------|
| `NEXT_PUBLIC_API_URL` | `https://api.sojorn.net` | Go backend base URL |
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `0x4AAAAAAC...` | Cloudflare Turnstile site key (invisible mode) |
### Backend (`/opt/sojorn/.env`)
The admin system uses these existing environment variables:
| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `JWT_SECRET` | Secret for signing/verifying JWT tokens |
| `TURNSTILE_SECRET` | Cloudflare Turnstile server-side verification key |
| `PORT` | API server port (default: `8080`) |
---
## Troubleshooting
### Admin login returns "Admin access required"
The user's profile doesn't have `role = 'admin'`. Fix:
```sql
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
```
### Admin login returns "Invalid credentials"
- Verify the email matches what's in the `users` table
- Password is validated against `encrypted_password` via bcrypt
- Check the user's `status` is `active` (not `pending`, `suspended`, or `banned`)
### Admin login returns "Security verification failed"
Cloudflare Turnstile rejected the request. Possible causes:
- `TURNSTILE_SECRET` in backend `.env` doesn't match the site key in frontend `.env.local`
- The Turnstile widget hostname doesn't include `admin.sojorn.net` in Cloudflare dashboard
- Bot or automated request without a valid Turnstile token
### sojorn-admin service won't start
```bash
# Check logs
sudo journalctl -u sojorn-admin -n 50 --no-pager
# Check if port 3002 is in use
ss -tlnp | grep 3002
# If port is taken, find and kill the process
sudo fuser -k 3002/tcp
sudo systemctl restart sojorn-admin
```
### sojorn-api panics on startup
Check for duplicate route registrations in `cmd/api/main.go`. The Go backend will panic if two routes resolve to the same path (e.g., legacy routes conflicting with admin group routes).
### Frontend shows "Loading..." indefinitely
- Check browser console for network errors
- Verify `NEXT_PUBLIC_API_URL` in `.env.local` is correct and reachable
- Ensure the Go API is running: `sudo systemctl status sojorn-api`
- Check CORS — the backend must allow the admin domain in `CORS_ORIGINS`
### Database migration errors
Run the migration manually:
```bash
export PGPASSWORD=your_db_password
psql -U postgres -h localhost -d sojorn -f /opt/sojorn/go-backend/internal/database/migrations/20260206000001_admin_panel_tables.up.sql
```
### PM2 conflicts
Port 3001 is used by another PM2-managed site on this server. The admin panel uses port **3002** to avoid conflicts. Do not change it to 3001.

View file

@ -3,19 +3,35 @@
import AdminShell from '@/components/AdminShell'; import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Brain, Search, Check, Power, PowerOff, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Sparkles } from 'lucide-react'; import { Brain, Search, Check, ChevronDown, Play, Loader2, Eye, MessageSquare, Video, Shield, MapPin, Users, AlertTriangle, Server, Cloud, Cpu, Terminal, Upload } from 'lucide-react';
const MODERATION_TYPES = [ const MODERATION_TYPES = [
{ key: 'text', label: 'Text Moderation', icon: MessageSquare, desc: 'Analyze post text, comments, and captions for policy violations' }, { key: 'text', label: 'Text Moderation', icon: MessageSquare },
{ key: 'image', label: 'Image Moderation', icon: Eye, desc: 'Analyze uploaded images for inappropriate content (requires vision model)' }, { key: 'image', label: 'Image Moderation', icon: Eye },
{ key: 'video', label: 'Video Moderation', icon: Video, desc: 'Analyze video frames extracted from Quips (requires vision model)' }, { key: 'video', label: 'Video Moderation', icon: Video },
{ key: 'group_text', label: 'Group Chat', icon: Users },
{ key: 'group_image', label: 'Group Image', icon: Shield },
{ key: 'beacon_text', label: 'Beacon Text', icon: MapPin },
{ key: 'beacon_image', label: 'Beacon Image', icon: AlertTriangle },
];
const ENGINES = [
{ id: 'local_ai', label: 'Local AI (Ollama)', icon: Cpu },
{ id: 'openrouter', label: 'OpenRouter', icon: Cloud },
{ id: 'openai', label: 'OpenAI', icon: Server },
{ id: 'google', label: 'Google Vision', icon: Eye },
{ id: 'azure', label: 'Azure OpenAI', icon: Cloud },
];
const LOCAL_MODELS = [
{ id: 'llama-guard3:1b', name: 'LLaMA Guard 3 (1B)' },
{ id: 'qwen2.5:7b-instruct-q4_K_M', name: 'Qwen 2.5 (7B)' },
]; ];
interface ModelInfo { interface ModelInfo {
id: string; id: string;
name: string; name: string;
description?: string; pricing: { prompt: string; completion: string };
pricing: { prompt: string; completion: string; image?: string };
context_length: number; context_length: number;
architecture?: Record<string, any>; architecture?: Record<string, any>;
} }
@ -27,153 +43,99 @@ interface ModerationConfig {
model_name: string; model_name: string;
system_prompt: string; system_prompt: string;
enabled: boolean; enabled: boolean;
engines: string[];
updated_at: string; updated_at: string;
} }
interface EngineInfo {
id: string;
name: string;
status: string;
}
export default function AIModerationPage() { export default function AIModerationPage() {
const [configs, setConfigs] = useState<ModerationConfig[]>([]); const [configs, setConfigs] = useState<ModerationConfig[]>([]);
const [engines, setEngines] = useState<EngineInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeType, setActiveType] = useState('text');
// Selection states
const [selectedType, setSelectedType] = useState('text');
const [selectedEngine, setSelectedEngine] = useState('local_ai');
// Config states
const [enabled, setEnabled] = useState(false);
const [modelId, setModelId] = useState('');
const [modelName, setModelName] = useState('');
const [systemPrompt, setSystemPrompt] = useState('');
const [saving, setSaving] = useState(false);
// OpenRouter model picker
const [showPicker, setShowPicker] = useState(false);
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// Test states
const [testInput, setTestInput] = useState('');
const [testResponse, setTestResponse] = useState<any>(null);
const [testing, setTesting] = useState(false);
const [testHistory, setTestHistory] = useState<any[]>([]);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const loadConfigs = useCallback(() => { const loadConfigs = useCallback(() => {
setLoading(true); setLoading(true);
api.getAIModerationConfigs() Promise.all([
.then((data) => setConfigs(data.configs || [])) api.getAIModerationConfigs(),
.catch(() => {}) api.getAIEngines()
])
.then(([configData, engineData]) => {
setConfigs(configData.configs || []);
setEngines(engineData.engines || []);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => { loadConfigs(); }, [loadConfigs]); useEffect(() => { loadConfigs(); }, [loadConfigs]);
const getConfig = (type: string) => configs.find(c => c.moderation_type === type); // Load config when type changes
useEffect(() => {
const config = configs.find(c => c.moderation_type === selectedType);
if (config) {
setEnabled(config.enabled);
setModelId(config.model_id || '');
setModelName(config.model_name || '');
setSystemPrompt(config.system_prompt || '');
if (config.engines && config.engines.length > 0) {
setSelectedEngine(config.engines[0]);
}
} else {
setEnabled(false);
setModelId('');
setModelName('');
setSystemPrompt('');
}
}, [selectedType, configs]);
return ( const loadModels = useCallback((search?: string) => {
<AdminShell>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Brain className="w-6 h-6" /> AI Moderation
</h1>
<p className="text-sm text-gray-500 mt-1">Configure AI models for content moderation via OpenRouter</p>
</div>
{/* Config Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{MODERATION_TYPES.map((mt) => {
const config = getConfig(mt.key);
const Icon = mt.icon;
return (
<button
key={mt.key}
onClick={() => setActiveType(mt.key)}
className={`card p-4 text-left transition-all ${
activeType === mt.key ? 'ring-2 ring-brand-500 shadow-md' : 'hover:shadow-sm'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icon className="w-5 h-5 text-gray-600" />
<span className="font-semibold text-gray-900 text-sm">{mt.label}</span>
</div>
{config?.enabled ? (
<span className="flex items-center gap-1 text-xs font-medium text-green-700 bg-green-100 px-2 py-0.5 rounded-full">
<Power className="w-3 h-3" /> On
</span>
) : (
<span className="flex items-center gap-1 text-xs font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
<PowerOff className="w-3 h-3" /> Off
</span>
)}
</div>
<p className="text-xs text-gray-400 mb-2">{mt.desc}</p>
{config?.model_id ? (
<p className="text-xs font-mono text-brand-600 truncate">{config.model_name || config.model_id}</p>
) : (
<p className="text-xs text-gray-300 italic">No model selected</p>
)}
</button>
);
})}
</div>
{/* Active Config Editor */}
<ConfigEditor
key={activeType}
moderationType={activeType}
config={getConfig(activeType)}
onSaved={loadConfigs}
/>
</AdminShell>
);
}
// ─── Config Editor for a single moderation type ─────────
function ConfigEditor({ moderationType, config, onSaved }: {
moderationType: string;
config?: ModerationConfig;
onSaved: () => void;
}) {
const [modelId, setModelId] = useState(config?.model_id || '');
const [modelName, setModelName] = useState(config?.model_name || '');
const [systemPrompt, setSystemPrompt] = useState(config?.system_prompt || '');
const [enabled, setEnabled] = useState(config?.enabled || false);
const [saving, setSaving] = useState(false);
// Model picker
const [showPicker, setShowPicker] = useState(false);
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [capability, setCapability] = useState('');
const searchTimer = useRef<any>(null);
// Test
const [testInput, setTestInput] = useState('');
const [testResult, setTestResult] = useState<any>(null);
const [testing, setTesting] = useState(false);
const loadModels = useCallback((search?: string, cap?: string) => {
setModelsLoading(true); setModelsLoading(true);
api.listOpenRouterModels({ search, capability: cap }) api.listOpenRouterModels({ search })
.then((data) => setModels(data.models || [])) .then((data) => setModels(data.models || []))
.catch(() => {})
.finally(() => setModelsLoading(false)); .finally(() => setModelsLoading(false));
}, []); }, []);
useEffect(() => {
if (showPicker) loadModels(searchTerm || undefined, capability || undefined);
}, [showPicker]);
const onSearchChange = (val: string) => {
setSearchTerm(val);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
loadModels(val || undefined, capability || undefined);
}, 400);
};
const onCapabilityChange = (val: string) => {
setCapability(val);
loadModels(searchTerm || undefined, val || undefined);
};
const selectModel = (m: ModelInfo) => {
setModelId(m.id);
setModelName(m.name);
setShowPicker(false);
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
await api.setAIModerationConfig({ await api.setAIModerationConfig({
moderation_type: moderationType, moderation_type: selectedType,
model_id: modelId, model_id: modelId,
model_name: modelName, model_name: modelName,
system_prompt: systemPrompt, system_prompt: systemPrompt,
enabled, enabled,
engines: [selectedEngine],
}); });
onSaved(); loadConfigs();
} catch (e: any) { } catch (e: any) {
alert(e.message); alert(e.message);
} finally { } finally {
@ -181,249 +143,407 @@ function ConfigEditor({ moderationType, config, onSaved }: {
} }
}; };
const handleTest = async () => { const handleFileUpload = async (file: File) => {
if (!testInput.trim()) return; setUploading(true);
setTesting(true);
setTestResult(null);
try { try {
const data = moderationType === 'text' const result = await api.uploadTestImage(file);
? { moderation_type: moderationType, content: testInput } setTestInput(result.url);
: { moderation_type: moderationType, image_url: testInput }; setUploadedFile(file);
const res = await api.testAIModeration(data);
setTestResult(res.result);
} catch (e: any) { } catch (e: any) {
setTestResult({ error: e.message }); console.error('Upload error:', e);
alert('Upload failed: ' + e.message);
} finally {
setUploading(false);
}
};
const handleTest = async () => {
if (!testInput.trim() && !uploadedFile) return;
setTesting(true);
const startTime = Date.now();
try {
const isImage = selectedType.includes('image') || selectedType === 'video';
const data: any = {
moderation_type: selectedType,
engine: selectedEngine,
};
if (isImage) {
data.image_url = testInput; // Use the uploaded file URL
} else {
data.content = testInput;
}
const res = await api.testAIModeration(data);
const duration = Date.now() - startTime;
const entry = { ...res, timestamp: new Date().toISOString(), duration };
setTestResponse(entry);
setTestHistory(prev => [entry, ...prev].slice(0, 10));
} catch (e: any) {
const entry = {
error: e.message,
engine: selectedEngine,
moderation_type: selectedType,
input: testInput,
timestamp: new Date().toISOString(),
duration: Date.now() - startTime
};
setTestResponse(entry);
setTestHistory(prev => [entry, ...prev].slice(0, 10));
} finally { } finally {
setTesting(false); setTesting(false);
} }
}; };
const isFree = (m: ModelInfo) => m.pricing.prompt === '0' || m.pricing.prompt === '0.0'; const typeLabel = MODERATION_TYPES.find(t => t.key === selectedType)?.label || selectedType;
const isVision = (m: ModelInfo) => { const engineLabel = ENGINES.find(e => e.id === selectedEngine)?.label || selectedEngine;
const modality = m.architecture?.modality;
return typeof modality === 'string' && modality.includes('image'); const getEngineStatus = (id: string) => {
const engine = engines.find(e => e.id === id);
if (!engine) return { color: 'text-gray-400', dot: 'bg-gray-300', label: 'Unknown' };
if (engine.status === 'ready') return { color: 'text-green-600', dot: 'bg-green-500', label: 'Online' };
if (engine.status === 'down') return { color: 'text-red-600', dot: 'bg-red-500', label: 'Down' };
return { color: 'text-gray-400', dot: 'bg-gray-300', label: 'Not Configured' };
}; };
return ( return (
<div className="space-y-4"> <AdminShell>
{/* Model Selection */} <div className="mb-6">
<div className="card p-5"> <h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<div className="flex items-center justify-between mb-4"> <Brain className="w-6 h-6" /> AI Moderation
<h2 className="font-semibold text-gray-900"> </h1>
{MODERATION_TYPES.find(t => t.key === moderationType)?.label} Configuration <p className="text-sm text-gray-500 mt-1">Configure AI moderation engines</p>
</h2>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-sm text-gray-500">Enabled</span>
<button
onClick={() => setEnabled(!enabled)}
className={`relative w-10 h-6 rounded-full transition-colors ${enabled ? 'bg-green-500' : 'bg-gray-300'}`}
>
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enabled ? 'left-[18px]' : 'left-0.5'}`} />
</button>
</label>
</div> </div>
{/* Selected Model */} {/* Engine Status - Compact */}
<div className="mb-4"> <div className="card p-4 mb-4">
<label className="text-sm font-medium text-gray-600 block mb-1">Model</label> <div className="flex items-center gap-4 text-sm">
<div className="flex gap-2"> <span className="font-semibold text-gray-700">Engine Status:</span>
{ENGINES.map(eng => {
const status = getEngineStatus(eng.id);
return (
<div key={eng.id} className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
<span className={status.color}>{eng.label}</span>
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Configuration */}
<div className="space-y-4">
{/* Type Selector */}
<div className="card p-4">
<label className="text-sm font-semibold text-gray-700 block mb-2">Moderation Type</label>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
{MODERATION_TYPES.map(t => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
</div>
{/* Engine Selector */}
<div className="card p-4">
<label className="text-sm font-semibold text-gray-700 block mb-2">Engine</label>
<select
value={selectedEngine}
onChange={(e) => setSelectedEngine(e.target.value)}
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
{ENGINES.map(e => (
<option key={e.id} value={e.id}>{e.label}</option>
))}
</select>
</div>
{/* AI Moderation Instructions */}
<div className="card p-4">
<label className="text-sm font-semibold text-gray-700 block mb-2">
Moderation Instructions
</label>
<p className="text-xs text-gray-500 mb-2">
Provide specific guidelines for the AI to follow when moderating {typeLabel.toLowerCase()} content.
</p>
<textarea
rows={4}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="Example: Flag content that promotes violence, hate speech, or illegal activities. Allow political discussion and criticism. Be lenient with humor and satire..."
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
{/* Engine Configuration */}
<div className="card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">{engineLabel} Configuration</h3>
<div className="text-xs text-gray-500">
Current: {modelName || 'Not configured'}
</div>
</div>
{selectedEngine === 'local_ai' && (
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
<select
value={modelId}
onChange={(e) => {
const selected = LOCAL_MODELS.find(m => m.id === e.target.value);
setModelId(e.target.value);
setModelName(selected?.name || '');
}}
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">Select model...</option>
{LOCAL_MODELS.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
)}
{selectedEngine === 'openrouter' && (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Model</label>
<div <div
onClick={() => setShowPicker(!showPicker)} onClick={() => setShowPicker(!showPicker)}
className="flex-1 flex items-center justify-between px-3 py-2 border border-warm-300 rounded-lg cursor-pointer hover:bg-warm-50 transition-colors" className="flex items-center justify-between px-3 py-2 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50"
> >
{modelId ? ( {modelId ? (
<div> <span className="text-sm">{modelName || modelId}</span>
<span className="text-sm font-medium text-gray-900">{modelName || modelId}</span>
<span className="text-xs text-gray-400 ml-2 font-mono">{modelId}</span>
</div>
) : ( ) : (
<span className="text-sm text-gray-400">Click to select a model...</span> <span className="text-sm text-gray-400">Select model...</span>
)} )}
<ChevronDown className="w-4 h-4 text-gray-400" /> <ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${showPicker ? 'rotate-180' : ''}`} />
</div>
</div>
</div> </div>
{/* Model Picker */}
{showPicker && ( {showPicker && (
<div className="mb-4 border border-warm-200 rounded-lg overflow-hidden"> <div className="mt-2 border border-gray-200 rounded-lg overflow-hidden">
<div className="p-3 bg-warm-100 flex gap-2"> <div className="p-2 bg-gray-50">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input <input
type="text" type="text"
placeholder="Search models..." placeholder="Search models..."
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => {
className="w-full pl-9 pr-3 py-2 text-sm border border-warm-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500" setSearchTerm(e.target.value);
loadModels(e.target.value);
}}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
/> />
</div> </div>
<select <div className="max-h-64 overflow-y-auto">
value={capability}
onChange={(e) => onCapabilityChange(e.target.value)}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All Models</option>
<option value="free">Free Only</option>
<option value="vision">Vision Only</option>
</select>
</div>
<div className="max-h-80 overflow-y-auto">
{modelsLoading ? ( {modelsLoading ? (
<div className="p-4 text-center text-gray-400 text-sm flex items-center justify-center gap-2"> <div className="p-4 text-center text-sm text-gray-400">Loading...</div>
<Loader2 className="w-4 h-4 animate-spin" /> Loading models...
</div>
) : models.length === 0 ? (
<div className="p-4 text-center text-gray-400 text-sm">No models found</div>
) : ( ) : (
models.map((m) => ( models.map(m => (
<div <div
key={m.id} key={m.id}
onClick={() => selectModel(m)} onClick={() => {
className={`px-3 py-2.5 border-b border-warm-100 cursor-pointer hover:bg-brand-50 transition-colors ${modelId === m.id ? 'bg-brand-50' : ''}`} setModelId(m.id);
setModelName(m.name);
setShowPicker(false);
}}
className="px-3 py-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100"
> >
<div className="flex items-center justify-between"> <div className="text-sm font-medium">{m.name}</div>
<div className="flex items-center gap-2 min-w-0"> <div className="text-xs text-gray-400">{m.id}</div>
{modelId === m.id && <Check className="w-4 h-4 text-brand-600 flex-shrink-0" />}
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{m.name}</div>
<div className="text-xs text-gray-400 font-mono truncate">{m.id}</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{isVision(m) && (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">Vision</span>
)}
{isFree(m) ? (
<span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded font-medium">Free</span>
) : (
<span className="text-xs text-gray-400">${m.pricing.prompt}/tok</span>
)}
<span className="text-xs text-gray-300">{(m.context_length / 1000).toFixed(0)}k ctx</span>
</div>
</div>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </div>
)} )}
</div>
</div>
)}
{/* System Prompt */} {selectedEngine === 'openai' && (
<div className="mb-4"> <p className="text-xs text-gray-500">OpenAI moderation is automatically configured. No additional settings needed.</p>
<label className="text-sm font-medium text-gray-600 block mb-1"> )}
System Prompt <span className="text-gray-400 font-normal">(leave blank for default)</span>
</label> {selectedEngine === 'google' && (
<textarea <p className="text-xs text-gray-500">Google Vision SafeSearch is configured via service account. No additional settings needed.</p>
rows={5} )}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)} {selectedEngine === 'azure' && (
placeholder="Custom system prompt for this moderation type... Leave blank to use the built-in default." <div>
className="w-full text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500 font-mono" <label className="text-xs font-medium text-gray-600 block mb-1">Deployment Name</label>
<input
type="text"
value={modelId}
onChange={(e) => {
setModelId(e.target.value);
setModelName(e.target.value);
}}
placeholder="e.g., gpt-4o-vision"
className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/> />
<p className="text-xs text-gray-500 mt-2">
Azure OpenAI deployment name (configured in Azure portal). Uses your Azure credits.
</p>
</div>
)}
</div> </div>
{/* Save */} {/* Save Button */}
<div className="flex items-center justify-between"> <div className="card p-4 flex items-center justify-between">
<div className="text-xs text-gray-400"> <label className="flex items-center gap-2 cursor-pointer">
{config?.updated_at && `Last updated: ${new Date(config.updated_at).toLocaleString()}`} <input
</div> type="checkbox"
<button onClick={handleSave} disabled={saving} className="btn-primary text-sm flex items-center gap-1.5"> checked={enabled}
<Sparkles className="w-4 h-4" /> {saving ? 'Saving...' : 'Save Configuration'} onChange={(e) => setEnabled(e.target.checked)}
className="w-4 h-4 text-brand-600 rounded focus:ring-2 focus:ring-brand-500"
/>
<span className="text-sm font-medium text-gray-700">Enable {typeLabel}</span>
</label>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary text-sm disabled:opacity-40"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button> </button>
</div> </div>
</div> </div>
{/* Test Panel */} {/* Right: Test Terminal */}
<div className="card p-5"> <div className="space-y-4">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"> {/* Test Input */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Play className="w-4 h-4" /> Test Moderation <Play className="w-4 h-4" /> Test Moderation
</h3> </h3>
<p className="text-xs text-gray-400 mb-3">
{moderationType === 'text' {(selectedType.includes('image') || selectedType === 'video') ? (
? 'Enter text content to test moderation' <div className="space-y-3">
: 'Enter an image URL to test vision moderation'} {/* File Upload */}
</p> <div className="flex gap-2">
<div className="flex gap-2 mb-3"> <label className="flex-1">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}}
className="hidden"
/>
<div className="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500 cursor-pointer hover:bg-gray-50 flex items-center justify-center gap-2">
<Upload className="w-4 h-4" />
{uploading ? 'Uploading...' : (uploadedFile ? `Uploaded: ${uploadedFile.name}` : 'Click to upload image...')}
</div>
</label>
</div>
{/* URL Input */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>OR</span>
</div>
<div className="flex gap-2">
<input <input
type="text" type="text"
value={testInput} value={testInput}
onChange={(e) => setTestInput(e.target.value)} onChange={(e) => setTestInput(e.target.value)}
placeholder={moderationType === 'text' ? 'Enter test text...' : 'Enter image URL...'} placeholder="Image URL..."
className="flex-1 text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500" className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
/> />
</div>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
placeholder="Test text..."
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
/>
</div>
)}
<div className="flex gap-2 mt-3">
<button <button
onClick={handleTest} onClick={handleTest}
disabled={testing || !modelId || !enabled} disabled={testing || (!testInput.trim() && !uploadedFile)}
className="btn-primary text-sm flex items-center gap-1.5 disabled:opacity-40" className="btn-primary text-sm flex items-center gap-1.5 disabled:opacity-40"
> >
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />} {testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
Test Test
</button> </button>
{uploadedFile && (
<button
onClick={() => {
setUploadedFile(null);
setTestInput('');
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear Upload
</button>
)}
</div>
</div> </div>
{!modelId && <p className="text-xs text-amber-600">Select and save a model first to test</p>}
{testResult && ( {/* Terminal Output */}
<div className={`p-4 rounded-lg text-sm ${testResult.error ? 'bg-red-50 text-red-700' : testResult.action === 'flag' ? 'bg-red-50' : testResult.action === 'nsfw' ? 'bg-amber-50' : 'bg-green-50'}`}> <div className="card p-0 overflow-hidden">
{testResult.error ? ( <div className="bg-gray-900 px-4 py-2 flex items-center gap-2 border-b border-gray-700">
<p>{testResult.error}</p> <Terminal className="w-4 h-4 text-green-400" />
<span className="text-xs font-mono text-green-400">moderation-test</span>
</div>
<div className="bg-gray-950 p-4 font-mono text-xs text-green-400 h-[500px] overflow-y-auto">
{testHistory.length === 0 ? (
<div className="text-gray-600">Waiting for test input...</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-4">
{/* Verdict */} {testHistory.map((entry, idx) => (
<div className="flex items-center gap-2 flex-wrap"> <div key={idx} className="border-b border-gray-800 pb-3 last:border-0">
<span className={`text-lg font-bold ${testResult.action === 'flag' ? 'text-red-700' : testResult.action === 'nsfw' ? 'text-amber-700' : 'text-green-700'}`}> <div className="text-gray-500 mb-1">
{testResult.action === 'flag' ? '⛔ FLAGGED' : testResult.action === 'nsfw' ? '⚠️ NSFW' : '✅ CLEAN'} [{new Date(entry.timestamp).toLocaleTimeString()}] {entry.engine} {entry.moderation_type} {entry.duration}ms
</span>
{testResult.nsfw_reason && (
<span className="text-xs font-medium bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full">{testResult.nsfw_reason}</span>
)}
{testResult.reason && <span className="text-gray-600"> {testResult.reason}</span>}
</div> </div>
{/* Overall Explanation */} {entry.error ? (
{testResult.explanation && ( <div className="text-red-400">ERROR: {entry.error}</div>
<div className="bg-white/60 rounded-lg p-3 border border-warm-200"> ) : entry.result ? (
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">AI Analysis</p> <div className="space-y-1">
<p className="text-sm text-gray-700 leading-relaxed">{testResult.explanation}</p> <div className={entry.result.flagged ? 'text-red-400' : 'text-green-400'}>
{entry.result.flagged ? '⛔ FLAGGED' : '✅ CLEAN'}
{entry.result.reason && `: ${entry.result.reason}`}
</div>
{entry.result.explanation && (
<div className="text-gray-400 pl-4">{entry.result.explanation}</div>
)}
{entry.result.hate !== undefined && (
<div className="text-gray-400 pl-4">
Hate: {(entry.result.hate * 100).toFixed(1)}% |
Greed: {(entry.result.greed * 100).toFixed(1)}% |
Delusion: {(entry.result.delusion * 100).toFixed(1)}%
</div> </div>
)} )}
{entry.result.categories && entry.result.categories.length > 0 && (
{/* Score Bars with Detail */} <div className="text-yellow-400 pl-4">
<div className="space-y-2"> Categories: {entry.result.categories.join(', ')}
<ScoreBarDetailed label="Hate" value={testResult.hate} detail={testResult.hate_detail} />
<ScoreBarDetailed label="Greed" value={testResult.greed} detail={testResult.greed_detail} />
<ScoreBarDetailed label="Delusion" value={testResult.delusion} detail={testResult.delusion_detail} />
</div>
{testResult.raw_content && (
<details className="mt-2">
<summary className="text-xs text-gray-400 cursor-pointer">Raw model response</summary>
<pre className="mt-1 text-xs bg-white p-2 rounded border border-warm-200 overflow-x-auto whitespace-pre-wrap">{testResult.raw_content}</pre>
</details>
)}
</div> </div>
)} )}
</div> </div>
) : null}
</div>
))}
</div>
)} )}
</div> </div>
</div> </div>
); </div>
} </div>
</AdminShell>
function ScoreBarDetailed({ label, value, detail }: { label: string; value: number; detail?: string }) {
const pct = Math.round((value || 0) * 100);
const color = pct > 50 ? 'bg-red-500' : pct > 25 ? 'bg-amber-400' : 'bg-green-400';
const textColor = pct > 50 ? 'text-red-700' : pct > 25 ? 'text-amber-700' : 'text-green-700';
return (
<div className="bg-white/50 rounded-lg p-2.5 border border-warm-100">
<div className="flex justify-between text-xs mb-1">
<span className="font-semibold text-gray-700">{label}</span>
<span className={`font-mono font-bold ${textColor}`}>{pct}%</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
{detail && <p className="text-xs text-gray-500 leading-relaxed">{detail}</p>}
</div>
); );
} }

View file

@ -1,89 +1,57 @@
'use client'; 'use client';
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import Script from 'next/script'; import Altcha from '@/components/Altcha';
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '';
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [turnstileToken, setTurnstileToken] = useState(''); const [altchaToken, setAltchaToken] = useState('');
const [turnstileReady, setTurnstileReady] = useState(false); const [altchaVerified, setAltchaVerified] = useState(false);
const turnstileRef = useRef<HTMLDivElement>(null); const emailRef = useRef('');
const widgetIdRef = useRef<string | null>(null); const passwordRef = useRef('');
const tokenRef = useRef('');
const { login } = useAuth(); const { login } = useAuth();
const router = useRouter(); const router = useRouter();
// Keep ref in sync with state so the submit handler always has the latest value const handleAltchaVerified = useCallback((payload: string) => {
useEffect(() => { tokenRef.current = turnstileToken; }, [turnstileToken]); setAltchaToken(payload);
setAltchaVerified(true);
const renderTurnstile = useCallback(() => {
if (!TURNSTILE_SITE_KEY || !turnstileRef.current || !(window as any).turnstile) return;
if (widgetIdRef.current) {
try { (window as any).turnstile.remove(widgetIdRef.current); } catch {}
}
widgetIdRef.current = (window as any).turnstile.render(turnstileRef.current, {
sitekey: TURNSTILE_SITE_KEY,
size: 'normal',
theme: 'light',
callback: (token: string) => { setTurnstileToken(token); tokenRef.current = token; setTurnstileReady(true); },
'error-callback': () => { setTurnstileToken(''); tokenRef.current = ''; setTurnstileReady(false); },
'expired-callback': () => { setTurnstileToken(''); tokenRef.current = ''; setTurnstileReady(false); },
});
}, []); }, []);
useEffect(() => { const handleAltchaError = useCallback(() => {
if ((window as any).turnstile && TURNSTILE_SITE_KEY) { setAltchaToken('');
renderTurnstile(); setAltchaVerified(false);
} }, []);
}, [renderTurnstile]);
const refreshTurnstile = () => { const performLogin = useCallback(async () => {
setTurnstileToken(''); if (!altchaToken) {
tokenRef.current = ''; setError('Please complete the security verification');
setTurnstileReady(false);
setError('');
if (widgetIdRef.current && (window as any).turnstile) {
(window as any).turnstile.reset(widgetIdRef.current);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (TURNSTILE_SITE_KEY && !tokenRef.current) {
setError('Please complete the security check first.');
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await login(email, password, tokenRef.current); await login(emailRef.current, passwordRef.current, altchaToken);
router.push('/'); router.push('/');
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Login failed. Check your credentials.'); setError(err.message || 'Login failed. Check your credentials.');
// Reset turnstile for retry
refreshTurnstile();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [login, router, altchaToken]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
await performLogin();
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-warm-100"> <div className="min-h-screen flex items-center justify-center bg-warm-100">
{TURNSTILE_SITE_KEY && (
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
onReady={renderTurnstile}
/>
)}
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="card p-8"> <div className="card p-8">
<div className="text-center mb-8"> <div className="text-center mb-8">
@ -109,7 +77,11 @@ export default function LoginPage() {
type="email" type="email"
className="input" className="input"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
const v = e.target.value;
emailRef.current = v;
setEmail(v);
}}
placeholder="admin@sojorn.net" placeholder="admin@sojorn.net"
required required
/> />
@ -120,28 +92,26 @@ export default function LoginPage() {
type="password" type="password"
className="input" className="input"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
const v = e.target.value;
passwordRef.current = v;
setPassword(v);
}}
placeholder="••••••••" placeholder="••••••••"
required required
/> />
</div> </div>
{/* Visible Turnstile widget */} <div>
{TURNSTILE_SITE_KEY && ( <Altcha
<div className="flex flex-col items-center gap-2"> challengeurl="https://api.sojorn.net/api/v1/admin/altcha-challenge"
<div ref={turnstileRef} /> onVerified={handleAltchaVerified}
<button onError={handleAltchaError}
type="button" />
onClick={refreshTurnstile}
className="text-xs text-gray-400 hover:text-gray-600 underline"
>
Refresh verification
</button>
</div> </div>
)}
<button <button
type="submit" type="submit"
className="btn-primary w-full" className="btn-primary w-full"
disabled={loading || (!!TURNSTILE_SITE_KEY && !turnstileReady)} disabled={loading || !altchaVerified}
> >
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign In'}
</button> </button>

View file

@ -0,0 +1,325 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDate, truncate } from '@/lib/utils';
import { ChevronLeft, ChevronRight, Search, Shield, ShieldOff, MessageSquare, Users, Building2, Pin, PinOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
type Neighborhood = {
id: string;
name: string;
city: string;
state: string;
zip_code: string;
group_name: string;
member_count: number;
admin_count: number;
board_post_count: number;
group_post_count: number;
created_at: string;
};
type NeighborhoodAdmin = {
user_id: string;
role: 'owner' | 'admin' | 'member';
handle: string;
display_name: string;
avatar_url: string;
joined_at: string;
};
export default function NeighborhoodsPage() {
const [items, setItems] = useState<Neighborhood[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [zip, setZip] = useState('');
const [sort, setSort] = useState<'name' | 'zip' | 'members' | 'created'>('name');
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
const [offset, setOffset] = useState(0);
const [selected, setSelected] = useState<Neighborhood | null>(null);
const [boardEntries, setBoardEntries] = useState<any[]>([]);
const [boardLoading, setBoardLoading] = useState(false);
const [boardSearch, setBoardSearch] = useState('');
const [adminUserId, setAdminUserId] = useState('');
const [admins, setAdmins] = useState<NeighborhoodAdmin[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const limit = 25;
const selectedStats = useMemo(() => {
if (!selected) return null;
return [
{ label: 'Members', value: selected.member_count, icon: <Users className="w-4 h-4" /> },
{ label: 'Group Admins', value: selected.admin_count, icon: <Shield className="w-4 h-4" /> },
{ label: 'Board Posts', value: selected.board_post_count, icon: <MessageSquare className="w-4 h-4" /> },
{ label: 'Group Posts', value: selected.group_post_count, icon: <Building2 className="w-4 h-4" /> },
];
}, [selected]);
const fetchNeighborhoods = () => {
setLoading(true);
api
.listNeighborhoods({ limit, offset, search: search || undefined, zip: zip || undefined, sort, order })
.then((data) => {
setItems(data.neighborhoods || []);
setTotal(data.total || 0);
})
.finally(() => setLoading(false));
};
const fetchBoardEntries = (id: string, searchTerm = '') => {
setBoardLoading(true);
api
.listNeighborhoodBoardEntries(id, { limit: 20, offset: 0, search: searchTerm || undefined })
.then((data) => setBoardEntries(data.entries || []))
.finally(() => setBoardLoading(false));
};
const fetchNeighborhoodAdmins = (id: string) => {
setAdminLoading(true);
api
.listNeighborhoodAdmins(id)
.then((data) => setAdmins(data.admins || []))
.finally(() => setAdminLoading(false));
};
useEffect(() => {
fetchNeighborhoods();
}, [offset, sort, order]);
const onSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
fetchNeighborhoods();
};
const onSelectNeighborhood = (n: Neighborhood) => {
setSelected(n);
setBoardSearch('');
fetchBoardEntries(n.id);
fetchNeighborhoodAdmins(n.id);
};
const toggleBoardEntry = async (entryId: string, current: boolean) => {
if (!selected) return;
await api.updateNeighborhoodBoardEntry(selected.id, entryId, !current);
fetchBoardEntries(selected.id, boardSearch);
fetchNeighborhoods();
};
const updateAdmin = async (action: 'assign' | 'remove') => {
if (!selected || !adminUserId.trim()) return;
await api.setNeighborhoodAdmin(selected.id, adminUserId.trim(), action);
setAdminUserId('');
fetchNeighborhoods();
fetchNeighborhoodAdmins(selected.id);
fetchBoardEntries(selected.id, boardSearch);
};
const removeAdminById = async (userId: string) => {
if (!selected || !userId) return;
await api.setNeighborhoodAdmin(selected.id, userId, 'remove');
fetchNeighborhoods();
fetchNeighborhoodAdmins(selected.id);
};
const toggleBoardPin = async (entryId: string, current: boolean) => {
if (!selected) return;
await api.pinNeighborhoodBoardEntry(selected.id, entryId, !current);
fetchBoardEntries(selected.id, boardSearch);
};
return (
<AdminShell>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Neighborhoods</h1>
<p className="text-sm text-gray-500 mt-1">Search, organize, and moderate neighborhood communities by name and ZIP.</p>
</div>
<div className="card p-4 mb-4 flex flex-wrap gap-3 items-center">
<form onSubmit={onSearch} className="flex-1 min-w-[220px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
className="input pl-10"
placeholder="Search neighborhood, city, state, ZIP..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</form>
<input
className="input w-32"
placeholder="ZIP"
value={zip}
onChange={(e) => setZip(e.target.value)}
/>
<select className="input w-auto" value={sort} onChange={(e) => setSort(e.target.value as any)}>
<option value="name">Sort: Name</option>
<option value="zip">Sort: ZIP</option>
<option value="members">Sort: Members</option>
<option value="created">Sort: Created</option>
</select>
<select className="input w-auto" value={order} onChange={(e) => setOrder(e.target.value as any)}>
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr,1fr] gap-4">
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">Neighborhood</th>
<th className="table-header">ZIP</th>
<th className="table-header">Members</th>
<th className="table-header">Admins</th>
<th className="table-header">Board</th>
<th className="table-header">Group</th>
<th className="table-header">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{loading ? (
[...Array(6)].map((_, i) => (
<tr key={i}>{[...Array(7)].map((__, j) => <td key={j} className="table-cell"><div className="h-4 bg-warm-300 rounded animate-pulse w-20" /></td>)}</tr>
))
) : items.length === 0 ? (
<tr><td colSpan={7} className="table-cell text-center text-gray-400 py-8">No neighborhoods found</td></tr>
) : (
items.map((n) => (
<tr key={n.id} className={`hover:bg-warm-50 cursor-pointer ${selected?.id === n.id ? 'bg-brand-50' : ''}`} onClick={() => onSelectNeighborhood(n)}>
<td className="table-cell">
<div>
<p className="font-medium text-gray-900">{n.name}</p>
<p className="text-xs text-gray-500">{truncate(`${n.city}, ${n.state}`, 30)}</p>
</div>
</td>
<td className="table-cell">{n.zip_code || '—'}</td>
<td className="table-cell">{n.member_count}</td>
<td className="table-cell">{n.admin_count}</td>
<td className="table-cell">{n.board_post_count}</td>
<td className="table-cell">{n.group_post_count}</td>
<td className="table-cell text-gray-500 text-xs">{formatDate(n.created_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="border-t border-warm-300 px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-500">Showing {Math.min(offset + 1, total)}{Math.min(offset + limit, total)} of {total}</p>
<div className="flex gap-2">
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}><ChevronLeft className="w-4 h-4" /></button>
<button className="btn-secondary text-sm py-1.5 px-3" disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}><ChevronRight className="w-4 h-4" /></button>
</div>
</div>
</div>
<div className="card p-4 min-h-[300px]">
{!selected ? (
<p className="text-sm text-gray-500">Select a neighborhood to manage admins and board content.</p>
) : (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{selected.name}</h2>
<p className="text-sm text-gray-500">{selected.city}, {selected.state} {selected.zip_code ? `· ${selected.zip_code}` : ''}</p>
</div>
<div className="grid grid-cols-2 gap-2">
{selectedStats?.map((stat) => (
<div key={stat.label} className="rounded-lg border border-warm-300 p-2 bg-warm-100">
<div className="flex items-center gap-2 text-gray-500 text-xs">{stat.icon}<span>{stat.label}</span></div>
<p className="text-lg font-semibold text-gray-900">{stat.value}</p>
</div>
))}
</div>
<div className="border border-warm-300 rounded-lg p-3 bg-warm-50">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Neighborhood Admins</p>
<div className="flex gap-2">
<input className="input" placeholder="User ID to assign/remove" value={adminUserId} onChange={(e) => setAdminUserId(e.target.value)} />
<button className="btn-primary text-sm" onClick={() => updateAdmin('assign')}><Shield className="w-4 h-4" />Assign</button>
<button className="btn-secondary text-sm" onClick={() => updateAdmin('remove')}><ShieldOff className="w-4 h-4" />Remove</button>
</div>
<div className="mt-3 rounded-lg border border-warm-300 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-warm-200 text-xs font-semibold text-gray-600">Current Moderators</div>
{adminLoading ? (
<p className="px-3 py-2 text-xs text-gray-500">Loading moderators</p>
) : admins.length === 0 ? (
<p className="px-3 py-2 text-xs text-gray-500">No admins found for this neighborhood.</p>
) : (
admins.map((mod) => (
<div key={mod.user_id} className="px-3 py-2 border-b border-warm-100 last:border-0 flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-gray-800">{mod.display_name || mod.handle || mod.user_id}</p>
<p className="text-xs text-gray-500">@{mod.handle || 'unknown'} · {mod.role}</p>
</div>
{mod.role === 'admin' && (
<button className="text-xs text-red-600 hover:text-red-700 font-medium" onClick={() => removeAdminById(mod.user_id)}>
Remove
</button>
)}
</div>
))
)}
</div>
</div>
<div className="border border-warm-300 rounded-lg overflow-hidden">
<div className="p-3 bg-warm-100 border-b border-warm-300 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Board Moderation</p>
<form
className="relative"
onSubmit={(e) => {
e.preventDefault();
if (selected) fetchBoardEntries(selected.id, boardSearch);
}}
>
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input className="input h-8 pl-7 text-xs" placeholder="Search posts" value={boardSearch} onChange={(e) => setBoardSearch(e.target.value)} />
</form>
</div>
<div className="max-h-80 overflow-y-auto">
{boardLoading ? (
<p className="p-3 text-xs text-gray-500">Loading board entries</p>
) : boardEntries.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No board entries in this neighborhood.</p>
) : (
boardEntries.map((entry) => (
<div key={entry.id} className="p-3 border-b border-warm-200 last:border-0">
<p className="text-sm text-gray-900 line-clamp-2">{entry.body}</p>
<div className="mt-1 text-xs text-gray-500 flex items-center justify-between">
<span>@{entry.author?.handle || 'unknown'} · {entry.topic || 'community'}</span>
<span>{entry.upvotes || 0} · {entry.reply_count || 0} replies</span>
</div>
<div className="mt-2">
<div className="flex items-center gap-3">
<button
className={`text-xs font-medium ${entry.is_active ? 'text-red-600 hover:text-red-700' : 'text-green-600 hover:text-green-700'}`}
onClick={() => toggleBoardEntry(entry.id, !!entry.is_active)}
>
{entry.is_active ? 'Hide post' : 'Restore post'}
</button>
<button
className={`text-xs font-medium inline-flex items-center gap-1 ${entry.is_pinned ? 'text-orange-600 hover:text-orange-700' : 'text-brand-600 hover:text-brand-700'}`}
onClick={() => toggleBoardPin(entry.id, !!entry.is_pinned)}
>
{entry.is_pinned ? <PinOff className="w-3.5 h-3.5" /> : <Pin className="w-3.5 h-3.5" />}
{entry.is_pinned ? 'Unpin' : 'Pin'}
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
</AdminShell>
);
}

View file

@ -8,46 +8,106 @@ import {
ChevronDown, ChevronUp, Bot, Clock, AlertCircle, CheckCircle, ExternalLink, ChevronDown, ChevronUp, Bot, Clock, AlertCircle, CheckCircle, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
// ─── Model Selector (fetches from OpenRouter) ───────── // ─── Model Selector (OpenRouter / Local Ollama / OpenAI) ─────────
type ProviderTab = 'openrouter' | 'local' | 'openai';
const OPENAI_MODELS = [
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' },
{ id: 'o1', name: 'o1' },
{ id: 'o1-mini', name: 'o1 Mini' },
{ id: 'o3-mini', name: 'o3 Mini' },
];
function detectProvider(modelId: string): ProviderTab {
if (modelId.startsWith('local/')) return 'local';
if (modelId.startsWith('openai/')) return 'openai';
return 'openrouter';
}
function stripPrefix(modelId: string): string {
if (modelId.startsWith('local/')) return modelId.slice(6);
if (modelId.startsWith('openai/')) return modelId.slice(7);
return modelId;
}
function ModelSelector({ value, onChange, className }: { value: string; onChange: (v: string) => void; className?: string }) { function ModelSelector({ value, onChange, className }: { value: string; onChange: (v: string) => void; className?: string }) {
const [models, setModels] = useState<{ id: string; name: string }[]>([]); const [tab, setTab] = useState<ProviderTab>(detectProvider(value));
const [orModels, setOrModels] = useState<{ id: string; name: string }[]>([]);
const [localModels, setLocalModels] = useState<{ id: string; name: string }[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
Promise.allSettled([
api.listOpenRouterModels().then((data) => { api.listOpenRouterModels().then((data) => {
const list = (data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id })); setOrModels((data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id })));
setModels(list); }),
}).catch(() => {}).finally(() => setLoading(false)); api.listLocalModels().then((data) => {
setLocalModels((data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id })));
}),
]).finally(() => setLoading(false));
}, []); }, []);
const currentModels = tab === 'openrouter' ? orModels : tab === 'local' ? localModels : OPENAI_MODELS;
const filtered = search const filtered = search
? models.filter((m) => m.id.toLowerCase().includes(search.toLowerCase()) || m.name.toLowerCase().includes(search.toLowerCase())) ? currentModels.filter((m) => m.id.toLowerCase().includes(search.toLowerCase()) || m.name.toLowerCase().includes(search.toLowerCase()))
: models; : currentModels;
const displayName = models.find((m) => m.id === value)?.name || value; const handleSelect = (rawId: string) => {
const prefixed = tab === 'local' ? `local/${rawId}` : tab === 'openai' ? `openai/${rawId}` : rawId;
onChange(prefixed);
setOpen(false);
setSearch('');
};
const rawValue = stripPrefix(value);
const allModels = [...orModels, ...localModels, ...OPENAI_MODELS];
const displayName = allModels.find((m) => m.id === rawValue)?.name || value;
const providerLabel = detectProvider(value) === 'local' ? 'Local' : detectProvider(value) === 'openai' ? 'OpenAI' : 'OR';
return ( return (
<div className={`relative ${className || ''}`}> <div className={`relative ${className || ''}`}>
<button type="button" onClick={() => setOpen(!open)} <button type="button" onClick={() => setOpen(!open)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm text-left truncate bg-white hover:bg-warm-50 transition-colors"> className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm text-left truncate bg-white hover:bg-warm-50 transition-colors flex items-center gap-1.5">
{loading ? 'Loading models...' : displayName} <span className={`text-[9px] px-1.5 py-0.5 rounded font-bold flex-shrink-0 ${
detectProvider(value) === 'local' ? 'bg-purple-100 text-purple-700' :
detectProvider(value) === 'openai' ? 'bg-green-100 text-green-700' :
'bg-blue-100 text-blue-700'
}`}>{providerLabel}</span>
<span className="truncate">{loading ? 'Loading...' : displayName}</span>
</button> </button>
{open && ( {open && (
<div className="absolute z-50 mt-1 w-full bg-white border border-warm-300 rounded-lg shadow-lg max-h-64 overflow-hidden flex flex-col"> <div className="absolute z-50 mt-1 w-full bg-white border border-warm-300 rounded-lg shadow-lg max-h-80 overflow-hidden flex flex-col min-w-[320px]">
<input type="text" placeholder="Search models..." value={search} onChange={(e) => setSearch(e.target.value)} autoFocus {/* Provider tabs */}
<div className="flex border-b border-warm-200">
{([['openrouter', 'OpenRouter', orModels.length], ['local', 'Local / Ollama', localModels.length], ['openai', 'OpenAI', OPENAI_MODELS.length]] as const).map(([key, label, count]) => (
<button key={key} type="button"
onClick={() => { setTab(key as ProviderTab); setSearch(''); }}
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
tab === key ? 'text-brand-700 border-b-2 border-brand-500 bg-brand-50' : 'text-gray-500 hover:text-gray-700 hover:bg-warm-50'
}`}>
{label} <span className="text-[10px] text-gray-400">({count})</span>
</button>
))}
</div>
<input type="text" placeholder={`Search ${tab} models...`} value={search} onChange={(e) => setSearch(e.target.value)} autoFocus
className="px-3 py-2 border-b border-warm-200 text-sm outline-none" /> className="px-3 py-2 border-b border-warm-200 text-sm outline-none" />
<div className="overflow-y-auto max-h-52"> <div className="overflow-y-auto max-h-52">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p className="p-3 text-xs text-gray-500">{loading ? 'Loading...' : 'No models found'}</p> <p className="p-3 text-xs text-gray-500">{loading ? 'Loading...' : tab === 'local' ? 'No local models (is Ollama running?)' : 'No models found'}</p>
) : ( ) : (
filtered.slice(0, 100).map((m) => ( filtered.slice(0, 100).map((m) => (
<button key={m.id} type="button" <button key={m.id} type="button"
onClick={() => { onChange(m.id); setOpen(false); setSearch(''); }} onClick={() => handleSelect(m.id)}
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-brand-50 transition-colors ${ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-brand-50 transition-colors ${
m.id === value ? 'bg-brand-50 text-brand-700 font-medium' : 'text-gray-700' m.id === rawValue && tab === detectProvider(value) ? 'bg-brand-50 text-brand-700 font-medium' : 'text-gray-700'
}`}> }`}>
<span className="block truncate font-medium">{m.name}</span> <span className="block truncate font-medium">{m.name}</span>
<span className="block truncate text-[10px] text-gray-400 font-mono">{m.id}</span> <span className="block truncate text-[10px] text-gray-400 font-mono">{m.id}</span>
@ -652,6 +712,17 @@ function EditAccountForm({ config, onDone }: { config: Config; onDone: () => voi
type PipelineTab = 'discovered' | 'posted' | 'failed' | 'skipped'; type PipelineTab = 'discovered' | 'posted' | 'failed' | 'skipped';
interface ArticleStats { discovered: number; posted: number; failed: number; skipped: number; total: number; } interface ArticleStats { discovered: number; posted: number; failed: number; skipped: number; total: number; }
function groupByDate(articles: any[], dateField: string): Record<string, any[]> {
const groups: Record<string, any[]> = {};
for (const a of articles) {
const raw = a[dateField] || a.discovered_at;
const date = raw ? new Date(raw).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : 'Unknown';
if (!groups[date]) groups[date] = [];
groups[date].push(a);
}
return groups;
}
function ArticlesPanel({ configId }: { configId: string }) { function ArticlesPanel({ configId }: { configId: string }) {
const [articles, setArticles] = useState<any[]>([]); const [articles, setArticles] = useState<any[]>([]);
const [stats, setStats] = useState<ArticleStats | null>(null); const [stats, setStats] = useState<ArticleStats | null>(null);
@ -660,6 +731,10 @@ function ArticlesPanel({ configId }: { configId: string }) {
const [bulkCount, setBulkCount] = useState(5); const [bulkCount, setBulkCount] = useState(5);
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
const [postResult, setPostResult] = useState<{ ok: boolean; msg: string } | null>(null); const [postResult, setPostResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [cleanupDate, setCleanupDate] = useState('');
const [cleanupAction, setCleanupAction] = useState<'skip' | 'delete'>('skip');
const [cleanupLoading, setCleanupLoading] = useState(false);
const fetchTab = async (t: PipelineTab) => { const fetchTab = async (t: PipelineTab) => {
setLoading(true); setLoading(true);
@ -669,7 +744,7 @@ function ArticlesPanel({ configId }: { configId: string }) {
setArticles(data.articles || []); setArticles(data.articles || []);
if (data.stats) setStats(data.stats); if (data.stats) setStats(data.stats);
} else { } else {
const data = await api.getPostedArticles(configId, 50, t); const data = await api.getPostedArticles(configId, 100, t);
setArticles(data.articles || []); setArticles(data.articles || []);
if (data.stats) setStats(data.stats); if (data.stats) setStats(data.stats);
} }
@ -697,6 +772,35 @@ function ArticlesPanel({ configId }: { configId: string }) {
setPosting(false); setPosting(false);
}; };
const handleArticleAction = async (articleId: string, action: 'post' | 'skip' | 'delete') => {
setActionLoading((p) => ({ ...p, [articleId]: true }));
try {
if (action === 'post') await api.postSpecificArticle(articleId);
else if (action === 'skip') await api.skipArticle(articleId);
else if (action === 'delete') await api.deleteArticle(articleId);
fetchTab(tab);
} catch (e: any) {
setPostResult({ ok: false, msg: `${action} failed: ${e.message}` });
}
setActionLoading((p) => ({ ...p, [articleId]: false }));
};
const handleCleanup = async () => {
if (!cleanupDate) return;
if (!confirm(`${cleanupAction === 'delete' ? 'Permanently delete' : 'Skip'} all pending articles before ${cleanupDate}?`)) return;
setCleanupLoading(true);
setPostResult(null);
try {
const resp = await api.cleanupPendingArticles(configId, cleanupDate, cleanupAction);
setPostResult({ ok: true, msg: resp.message });
if (resp.stats) setStats(resp.stats);
fetchTab(tab);
} catch (e: any) {
setPostResult({ ok: false, msg: e.message });
}
setCleanupLoading(false);
};
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [ const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' }, { key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' }, { key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
@ -706,6 +810,9 @@ function ArticlesPanel({ configId }: { configId: string }) {
const getCount = (key: PipelineTab) => stats ? stats[key] : 0; const getCount = (key: PipelineTab) => stats ? stats[key] : 0;
const dateField = tab === 'posted' ? 'posted_at' : tab === 'discovered' ? 'discovered_at' : 'discovered_at';
const grouped = groupByDate(articles, tab === 'discovered' ? 'pub_date' : dateField);
return ( return (
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden"> <div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
{/* Stats bar */} {/* Stats bar */}
@ -733,11 +840,15 @@ function ArticlesPanel({ configId }: { configId: string }) {
)} )}
</button> </button>
))} ))}
<button onClick={() => fetchTab(tab)} className="ml-auto px-2 py-1 text-gray-400 hover:text-gray-600">
<RefreshCw className="w-3 h-3" />
</button>
</div> </div>
{/* Bulk post controls — only on discovered tab */} {/* Bulk post controls — only on discovered tab */}
{tab === 'discovered' && stats && stats.discovered > 0 && ( {tab === 'discovered' && stats && stats.discovered > 0 && (
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50 border-b border-warm-200"> <div className="border-b border-warm-200">
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50">
<span className="text-[10px] font-medium text-gray-600">Post:</span> <span className="text-[10px] font-medium text-gray-600">Post:</span>
<input type="number" min={1} max={stats.discovered} value={bulkCount} <input type="number" min={1} max={stats.discovered} value={bulkCount}
onChange={(e) => setBulkCount(Math.max(1, Number(e.target.value)))} onChange={(e) => setBulkCount(Math.max(1, Number(e.target.value)))}
@ -750,42 +861,100 @@ function ArticlesPanel({ configId }: { configId: string }) {
className="px-2.5 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"> className="px-2.5 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50">
{posting ? 'Posting...' : `Post All (${stats.discovered})`} {posting ? 'Posting...' : `Post All (${stats.discovered})`}
</button> </button>
{postResult && ( </div>
<span className={`text-xs ml-auto ${postResult.ok ? 'text-green-600' : 'text-red-600'}`}>{postResult.msg}</span> <div className="flex items-center gap-2 px-3 py-2 bg-amber-50">
)} <span className="text-[10px] font-medium text-gray-600">Cleanup before:</span>
<input type="date" value={cleanupDate} onChange={(e) => setCleanupDate(e.target.value)}
className="px-1.5 py-1 border border-warm-300 rounded text-xs" />
<select value={cleanupAction} onChange={(e) => setCleanupAction(e.target.value as 'skip' | 'delete')}
className="px-1.5 py-1 border border-warm-300 rounded text-xs">
<option value="skip">Skip</option>
<option value="delete">Delete</option>
</select>
<button onClick={handleCleanup} disabled={cleanupLoading || !cleanupDate}
className="px-2.5 py-1 bg-amber-600 text-white rounded text-xs font-medium hover:bg-amber-700 disabled:opacity-50">
{cleanupLoading ? 'Cleaning...' : 'Cleanup'}
</button>
</div>
</div> </div>
)} )}
{/* Article list */} {/* Result message */}
<div className="max-h-56 overflow-y-auto"> {postResult && (
<div className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-warm-200 ${postResult.ok ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{postResult.ok ? <CheckCircle className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
<span>{postResult.msg}</span>
<button onClick={() => setPostResult(null)} className="ml-auto text-gray-400 hover:text-gray-600 text-[10px]"></button>
</div>
)}
{/* Article list grouped by date */}
<div className="max-h-80 overflow-y-auto">
{loading ? ( {loading ? (
<p className="p-3 text-xs text-gray-500">Loading...</p> <p className="p-3 text-xs text-gray-500">Loading...</p>
) : articles.length === 0 ? ( ) : articles.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No {tab} articles</p> <p className="p-3 text-xs text-gray-500">No {tab} articles</p>
) : tab === 'discovered' ? (
articles.map((a, i) => (
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source}</span>
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
{a.description && <p className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{a.description}</p>}
</div>
))
) : ( ) : (
articles.map((a) => ( Object.entries(grouped).map(([date, items]) => (
<div key={a.id} className="p-2 border-b border-warm-100 last:border-0"> <div key={date}>
<div className="flex items-center gap-2"> <div className="sticky top-0 px-3 py-1 bg-warm-100 border-b border-warm-200">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source_name}</span> <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">{date}</span>
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate"> <span className="text-[10px] text-gray-400 ml-2">({items.length})</span>
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{a.posted_at && <span className="text-[10px] text-gray-400 ml-auto flex-shrink-0">{new Date(a.posted_at).toLocaleString()}</span>}
</div> </div>
{items.map((a: any) => {
const id = a.id || a.link;
const isActioning = actionLoading[id];
return (
<div key={id} className="px-3 py-1.5 border-b border-warm-100 last:border-0 hover:bg-warm-50 group">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">
{a.source || a.source_name}
</span>
<a href={a.link} target="_blank" rel="noopener noreferrer"
className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate flex-1 min-w-0">
<span className="truncate">{a.title}</span>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{a.posted_at && (
<span className="text-[10px] text-gray-400 flex-shrink-0">
{new Date(a.posted_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
{/* Per-article actions for pending tab */}
{tab === 'discovered' && a.id && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button onClick={() => handleArticleAction(a.id, 'post')} disabled={isActioning}
title="Post this article" className="p-1 rounded hover:bg-green-100 text-green-600 disabled:opacity-50">
<Play className="w-3 h-3" />
</button>
<button onClick={() => handleArticleAction(a.id, 'skip')} disabled={isActioning}
title="Skip this article" className="p-1 rounded hover:bg-amber-100 text-amber-600 disabled:opacity-50">
<Clock className="w-3 h-3" />
</button>
<button onClick={() => handleArticleAction(a.id, 'delete')} disabled={isActioning}
title="Delete this article" className="p-1 rounded hover:bg-red-100 text-red-500 disabled:opacity-50">
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
{/* Delete action for other tabs */}
{tab !== 'discovered' && a.id && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button onClick={() => handleArticleAction(a.id, 'delete')} disabled={isActioning}
title="Delete this article" className="p-1 rounded hover:bg-red-100 text-red-500 disabled:opacity-50">
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{a.description && tab === 'discovered' && (
<p className="text-[10px] text-gray-500 mt-0.5 ml-[calc(1.5rem+0.5rem)] line-clamp-1">{a.description}</p>
)}
{a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>} {a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>}
</div> </div>
);
})}
</div>
)) ))
)} )}
</div> </div>

View file

@ -0,0 +1,700 @@
'use client';
import AdminShell from '@/components/AdminShell';
import SelectionBar from '@/components/SelectionBar';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState, useCallback } from 'react';
import {
Shield, CheckCircle, XCircle, Trash2, Ban, AlertTriangle, Scale, Flag,
RefreshCw, ChevronDown, ChevronUp, User, Clock, ExternalLink,
} from 'lucide-react';
import Link from 'next/link';
type Tab = 'moderation' | 'appeals' | 'reports';
// ─── Score Bar ────────────────────────────────────────
function ScoreBar({ label, value }: { label: string; value: number }) {
const pct = Math.round(value * 100);
const color = pct > 70 ? 'bg-red-500' : pct > 40 ? 'bg-yellow-500' : 'bg-green-500';
return (
<div className="flex items-center gap-2 text-xs">
<span className="w-16 text-gray-500">{label}</span>
<div className="flex-1 h-2 bg-warm-300 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right font-mono text-gray-600">{pct}%</span>
</div>
);
}
// ─── User Context Panel ───────────────────────────────
function UserContextPanel({ userId, handle }: { userId?: string; handle?: string }) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const fetchUser = useCallback(async () => {
if (!userId) return;
setLoading(true);
try {
const data = await api.getUser(userId);
setUser(data);
} catch {}
setLoading(false);
}, [userId]);
useEffect(() => {
if (open && !user && userId) fetchUser();
}, [open, user, userId, fetchUser]);
if (!userId) return null;
return (
<div className="mt-2">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 text-xs text-brand-600 hover:text-brand-800 transition-colors"
>
<User className="w-3 h-3" />
<span>@{handle || 'unknown'}</span>
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{open && (
<div className="mt-2 p-3 bg-warm-50 border border-warm-200 rounded-lg text-xs space-y-1.5">
{loading ? (
<p className="text-gray-400">Loading user context...</p>
) : user ? (
<>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-700">{user.display_name || user.handle}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
user.status === 'active' ? 'bg-green-100 text-green-700' :
user.status === 'suspended' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>{user.status}</span>
</div>
{user.violation_count != null && (
<p className="text-gray-500">
Violations: <span className={`font-medium ${user.violation_count > 0 ? 'text-red-600' : 'text-green-600'}`}>{user.violation_count}</span>
{user.warning_count > 0 && <span className="ml-2">Warnings: <span className="font-medium text-yellow-600">{user.warning_count}</span></span>}
</p>
)}
{user.created_at && (
<p className="text-gray-400">Joined {formatDateTime(user.created_at)}</p>
)}
<Link href={`/users/${userId}`} className="inline-flex items-center gap-1 text-brand-500 hover:text-brand-700">
View full profile <ExternalLink className="w-3 h-3" />
</Link>
</>
) : (
<p className="text-gray-400">User not found</p>
)}
</div>
)}
</div>
);
}
// ─── Tab Badge ────────────────────────────────────────
function TabBadge({ count }: { count: number }) {
if (count === 0) return null;
return (
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-red-500 text-white min-w-[18px] text-center">
{count > 99 ? '99+' : count}
</span>
);
}
// ─── Main Page ────────────────────────────────────────
export default function SafetyWorkspacePage() {
const [tab, setTab] = useState<Tab>('moderation');
// Moderation state
const [modItems, setModItems] = useState<any[]>([]);
const [modTotal, setModTotal] = useState(0);
const [modLoading, setModLoading] = useState(true);
const [modStatus, setModStatus] = useState('pending');
const [modSelected, setModSelected] = useState<Set<string>>(new Set());
const [modBulkLoading, setModBulkLoading] = useState(false);
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [banReason, setBanReason] = useState('');
const [customBanReason, setCustomBanReason] = useState(false);
// Appeals state
const [appeals, setAppeals] = useState<any[]>([]);
const [appealsTotal, setAppealsTotal] = useState(0);
const [appealsLoading, setAppealsLoading] = useState(true);
const [appealsStatus, setAppealsStatus] = useState('pending');
const [appealReviewId, setAppealReviewId] = useState<string | null>(null);
const [appealDecision, setAppealDecision] = useState('');
const [restoreContent, setRestoreContent] = useState(false);
// Reports state
const [reports, setReports] = useState<any[]>([]);
const [reportsTotal, setReportsTotal] = useState(0);
const [reportsLoading, setReportsLoading] = useState(true);
const [reportsStatus, setReportsStatus] = useState('pending');
const [reportsSelected, setReportsSelected] = useState<Set<string>>(new Set());
const [reportsBulkLoading, setReportsBulkLoading] = useState(false);
// Pending counts for badges
const [pendingCounts, setPendingCounts] = useState({ moderation: 0, appeals: 0, reports: 0 });
const banReasons = [
'Hate speech or slurs',
'Harassment or bullying',
'Spam or scam activity',
'Posting illegal content',
'Repeated violations after warnings',
'Ban evasion (alt account)',
];
// ── Fetch functions ──
const fetchModeration = useCallback(() => {
setModLoading(true);
api.getModerationQueue({ limit: 50, status: modStatus })
.then((data) => { setModItems(data.items || []); setModTotal(data.total || 0); })
.catch(() => {})
.finally(() => setModLoading(false));
}, [modStatus]);
const fetchAppeals = useCallback(() => {
setAppealsLoading(true);
api.listAppeals({ limit: 50, status: appealsStatus })
.then((data) => { setAppeals(data.appeals || []); setAppealsTotal(data.total || 0); })
.catch(() => {})
.finally(() => setAppealsLoading(false));
}, [appealsStatus]);
const fetchReports = useCallback(() => {
setReportsLoading(true);
api.listReports({ limit: 50, status: reportsStatus })
.then((data) => { setReports(data.reports || []); setReportsTotal(data.total || 0); })
.catch(() => {})
.finally(() => setReportsLoading(false));
}, [reportsStatus]);
const fetchPendingCounts = useCallback(() => {
Promise.allSettled([
api.getModerationQueue({ limit: 1, status: 'pending' }),
api.listAppeals({ limit: 1, status: 'pending' }),
api.listReports({ limit: 1, status: 'pending' }),
]).then(([mod, app, rep]) => {
setPendingCounts({
moderation: mod.status === 'fulfilled' ? (mod.value.total || 0) : 0,
appeals: app.status === 'fulfilled' ? (app.value.total || 0) : 0,
reports: rep.status === 'fulfilled' ? (rep.value.total || 0) : 0,
});
});
}, []);
useEffect(() => { fetchPendingCounts(); }, [fetchPendingCounts]);
useEffect(() => { fetchModeration(); }, [fetchModeration]);
useEffect(() => { fetchAppeals(); }, [fetchAppeals]);
useEffect(() => { fetchReports(); }, [fetchReports]);
const refreshAll = () => {
fetchModeration();
fetchAppeals();
fetchReports();
fetchPendingCounts();
};
// ── Moderation actions ──
const handleModReview = async (id: string, action: string) => {
try {
await api.reviewModerationFlag(id, action, banReason || 'Admin review');
setReviewingId(null);
setBanReason('');
fetchModeration();
fetchPendingCounts();
} catch (e: any) {
alert(`Action failed: ${e.message}`);
}
};
const handleModBulk = async (action: string) => {
setModBulkLoading(true);
try {
await api.bulkReviewModeration(Array.from(modSelected), action, 'Bulk admin review');
setModSelected(new Set());
fetchModeration();
fetchPendingCounts();
} catch (e: any) {
alert(`Bulk action failed: ${e.message}`);
}
setModBulkLoading(false);
};
// ── Appeal actions ──
const handleAppealReview = async (id: string, decision: 'approved' | 'rejected') => {
if (!appealDecision.trim()) return;
try {
await api.reviewAppeal(id, decision, appealDecision, restoreContent);
setAppealReviewId(null);
setAppealDecision('');
setRestoreContent(false);
fetchAppeals();
fetchPendingCounts();
} catch {}
};
// ── Report actions ──
const handleReportUpdate = async (id: string, status: string) => {
try {
await api.updateReportStatus(id, status);
fetchReports();
fetchPendingCounts();
} catch {}
};
const handleReportsBulk = async (action: string) => {
setReportsBulkLoading(true);
try {
await api.bulkUpdateReports(Array.from(reportsSelected), action);
setReportsSelected(new Set());
fetchReports();
fetchPendingCounts();
} catch {}
setReportsBulkLoading(false);
};
const totalPending = pendingCounts.moderation + pendingCounts.appeals + pendingCounts.reports;
return (
<AdminShell>
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-brand-500" />
Safety Workspace
{totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs font-bold rounded-full bg-red-500 text-white">
{totalPending} pending
</span>
)}
</h1>
<p className="text-sm text-gray-500 mt-1">Unified moderation, appeals, and reports management</p>
</div>
<button onClick={refreshAll} className="flex items-center gap-2 px-3 py-2 text-sm border border-warm-300 rounded-lg hover:bg-warm-200 transition-colors">
<RefreshCw className="w-4 h-4" /> Refresh All
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-warm-300 mb-6">
{([
['moderation', 'Moderation Queue', Shield, pendingCounts.moderation],
['appeals', 'Appeals', Scale, pendingCounts.appeals],
['reports', 'Reports', Flag, pendingCounts.reports],
] as const).map(([key, label, Icon, count]) => (
<button
key={key}
onClick={() => setTab(key as Tab)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
tab === key
? 'border-brand-500 text-brand-700 bg-brand-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-warm-400'
}`}
>
<Icon className="w-4 h-4" />
{label}
<TabBadge count={count as number} />
</button>
))}
</div>
{/* ═══ MODERATION TAB ═══ */}
{tab === 'moderation' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500">{modTotal} items {modStatus}</p>
<select className="input w-auto text-sm" value={modStatus} onChange={(e) => { setModStatus(e.target.value); setModSelected(new Set()); }}>
<option value="pending">Pending</option>
<option value="actioned">Actioned</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
{modStatus === 'pending' && (
<SelectionBar
count={modSelected.size}
total={modItems.length}
onSelectAll={() => setModSelected(new Set(modItems.map((i) => i.id)))}
onClearSelection={() => setModSelected(new Set())}
loading={modBulkLoading}
actions={[
{ label: 'Approve All', action: 'approve', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
{ label: 'Dismiss All', action: 'dismiss', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
{ label: 'Remove Content', action: 'remove_content', confirm: true, color: 'bg-red-50 text-red-700 hover:bg-red-100', icon: <Trash2 className="w-3.5 h-3.5" /> },
]}
onAction={handleModBulk}
/>
)}
{modLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-20 bg-warm-300 rounded" /></div>)}
</div>
) : modItems.length === 0 ? (
<div className="card p-12 text-center">
<Shield className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {modStatus} items in the queue</p>
<p className="text-sm text-gray-400 mt-1">All clear!</p>
</div>
) : (
<div className="space-y-4">
{modItems.map((item) => (
<div key={item.id} className={`card p-5 ${modSelected.has(item.id) ? 'ring-2 ring-brand-300' : ''}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
{modStatus === 'pending' && (
<input type="checkbox" className="rounded border-gray-300 mt-1" checked={modSelected.has(item.id)}
onChange={() => setModSelected((prev) => { const s = new Set(prev); s.has(item.id) ? s.delete(item.id) : s.add(item.id); return s; })} />
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`badge ${statusColor(item.status)}`}>{item.status}</span>
<span className="badge bg-gray-100 text-gray-600">{item.content_type}</span>
<span className="badge bg-red-50 text-red-700">
<AlertTriangle className="w-3 h-3 mr-1" />{item.flag_reason}
</span>
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{formatDateTime(item.created_at)}</span>
</div>
<div className="bg-warm-100 rounded-lg p-3 mb-3">
{item.content_type === 'post' ? (
<div>
<p className="text-sm text-gray-800 whitespace-pre-wrap">{item.post_body || 'No text content'}</p>
{item.post_image && <div className="mt-2 text-xs text-gray-400">Has image: {item.post_image}</div>}
{item.post_video && <div className="mt-1 text-xs text-gray-400">Has video: {item.post_video}</div>}
</div>
) : (
<p className="text-sm text-gray-800">{item.comment_body || 'No content'}</p>
)}
</div>
{/* Contextual user info */}
<UserContextPanel userId={item.author_id} handle={item.author_handle} />
{item.scores && (
<div className="space-y-1 max-w-xs mt-3">
{item.scores.hate != null && <ScoreBar label="Hate" value={item.scores.hate} />}
{item.scores.greed != null && <ScoreBar label="Greed" value={item.scores.greed} />}
{item.scores.delusion != null && <ScoreBar label="Delusion" value={item.scores.delusion} />}
</div>
)}
</div>
</div>
{item.status === 'pending' && (
<div className="flex flex-col gap-2 flex-shrink-0">
<button onClick={() => handleModReview(item.id, 'approve')}
className="flex items-center gap-1.5 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-xs font-medium hover:bg-green-100">
<CheckCircle className="w-4 h-4" /> Approve
</button>
<button onClick={() => handleModReview(item.id, 'dismiss')}
className="flex items-center gap-1.5 px-3 py-2 bg-gray-50 text-gray-600 rounded-lg text-xs font-medium hover:bg-gray-100">
<XCircle className="w-4 h-4" /> Dismiss
</button>
<button onClick={() => handleModReview(item.id, 'remove_content')}
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-xs font-medium hover:bg-red-100">
<Trash2 className="w-4 h-4" /> Remove
</button>
<button onClick={() => setReviewingId(item.id)}
className="flex items-center gap-1.5 px-3 py-2 bg-red-100 text-red-800 rounded-lg text-xs font-medium hover:bg-red-200">
<Ban className="w-4 h-4" /> Ban User
</button>
</div>
)}
</div>
{reviewingId === item.id && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm font-medium text-red-800 mb-2">Ban user and remove content</p>
<div className="space-y-1.5 mb-3">
{banReasons.map((preset) => (
<button key={preset}
onClick={() => { setBanReason(preset); setCustomBanReason(false); }}
className={`w-full text-left px-3 py-1.5 rounded text-xs border transition-colors ${
banReason === preset && !customBanReason
? 'border-red-400 bg-red-100 text-red-800 font-medium'
: 'border-red-200 hover:border-red-300 text-red-700'
}`}>{preset}</button>
))}
<button onClick={() => { setCustomBanReason(true); setBanReason(''); }}
className={`w-full text-left px-3 py-1.5 rounded text-xs border transition-colors ${
customBanReason ? 'border-red-400 bg-red-100 text-red-800 font-medium' : 'border-red-200 hover:border-red-300 text-red-700'
}`}>Custom reason...</button>
</div>
{customBanReason && (
<input className="input mb-2 text-sm" placeholder="Enter custom reason..." value={banReason} onChange={(e) => setBanReason(e.target.value)} autoFocus />
)}
<div className="flex gap-2">
<button onClick={() => { setReviewingId(null); setBanReason(''); setCustomBanReason(false); }} className="btn-secondary text-xs">Cancel</button>
<button onClick={() => handleModReview(item.id, 'ban_user')} className="btn-danger text-xs" disabled={!banReason.trim()}>Confirm Ban</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* ═══ APPEALS TAB ═══ */}
{tab === 'appeals' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500">{appealsTotal} {appealsStatus} appeals</p>
<select className="input w-auto text-sm" value={appealsStatus} onChange={(e) => setAppealsStatus(e.target.value)}>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
{appealsLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-24 bg-warm-300 rounded" /></div>)}
</div>
) : appeals.length === 0 ? (
<div className="card p-12 text-center">
<Scale className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {appealsStatus} appeals</p>
</div>
) : (
<div className="space-y-4">
{appeals.map((appeal) => (
<div key={appeal.id} className="card p-5">
<div className="flex items-center gap-2 mb-3">
<span className={`badge ${statusColor(appeal.status)}`}>{appeal.status}</span>
<span className="badge bg-orange-50 text-orange-700">{appeal.violation_type}</span>
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{formatDateTime(appeal.created_at)}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Violation</h4>
<div className="bg-red-50 rounded-lg p-3 mb-3">
<p className="text-sm font-medium text-red-800">{appeal.violation_reason}</p>
{appeal.flag_reason && <p className="text-xs text-red-600 mt-1">Flag: {appeal.flag_reason}</p>}
<p className="text-xs text-red-500 mt-1">Severity: {(appeal.severity_score * 100).toFixed(0)}%</p>
</div>
{(appeal.post_body || appeal.comment_body) && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">Original Content</h4>
<div className="bg-warm-100 rounded-lg p-3">
<p className="text-sm text-gray-700 whitespace-pre-wrap">{appeal.post_body || appeal.comment_body}</p>
</div>
</div>
)}
</div>
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Appeal by @{appeal.user?.handle || '—'}</h4>
<div className="bg-blue-50 rounded-lg p-3 mb-3">
<p className="text-sm text-blue-900 font-medium mb-1">Reason:</p>
<p className="text-sm text-blue-800">{appeal.appeal_reason}</p>
{appeal.appeal_context && (
<>
<p className="text-sm text-blue-900 font-medium mt-2 mb-1">Context:</p>
<p className="text-sm text-blue-800">{appeal.appeal_context}</p>
</>
)}
</div>
{/* Contextual user info */}
<UserContextPanel userId={appeal.user_id} handle={appeal.user?.handle} />
{appeal.flag_scores && (
<div className="text-xs text-gray-500 space-y-1 mt-3">
{Object.entries(appeal.flag_scores).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<span className="w-16">{key}</span>
<div className="flex-1 h-1.5 bg-warm-300 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${(value as number) > 0.7 ? 'bg-red-500' : (value as number) > 0.4 ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${(value as number) * 100}%` }} />
</div>
<span className="w-8 text-right font-mono">{((value as number) * 100).toFixed(0)}%</span>
</div>
))}
</div>
)}
</div>
</div>
{appeal.status === 'pending' && (
<>
{appealReviewId === appeal.id ? (
<div className="mt-4 p-4 bg-warm-100 border border-warm-400 rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-2">Write your review decision:</p>
<textarea className="input mb-3" rows={3} placeholder="Explain your decision (min 5 chars)..."
value={appealDecision} onChange={(e) => setAppealDecision(e.target.value)} />
<label className="flex items-center gap-2 text-sm text-gray-600 mb-3">
<input type="checkbox" checked={restoreContent} onChange={(e) => setRestoreContent(e.target.checked)} className="rounded" />
Restore original content (if approving)
</label>
<div className="flex gap-2">
<button onClick={() => { setAppealReviewId(null); setAppealDecision(''); }} className="btn-secondary text-sm">Cancel</button>
<button onClick={() => handleAppealReview(appeal.id, 'approved')}
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 flex items-center gap-1"
disabled={appealDecision.trim().length < 5}>
<CheckCircle className="w-4 h-4" /> Approve Appeal
</button>
<button onClick={() => handleAppealReview(appeal.id, 'rejected')}
className="btn-danger text-sm flex items-center gap-1"
disabled={appealDecision.trim().length < 5}>
<XCircle className="w-4 h-4" /> Reject Appeal
</button>
</div>
</div>
) : (
<div className="mt-4 flex gap-2">
<button onClick={() => setAppealReviewId(appeal.id)} className="btn-primary text-sm flex items-center gap-1">
<Scale className="w-4 h-4" /> Review This Appeal
</button>
</div>
)}
</>
)}
{appeal.review_decision && (
<div className="mt-4 p-3 bg-warm-100 rounded-lg">
<p className="text-xs font-semibold text-gray-500 uppercase mb-1">Review Decision</p>
<p className="text-sm text-gray-700">{appeal.review_decision}</p>
{appeal.reviewed_at && <p className="text-xs text-gray-400 mt-1">Reviewed {formatDateTime(appeal.reviewed_at)}</p>}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* ═══ REPORTS TAB ═══ */}
{tab === 'reports' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-500">{reportsTotal} {reportsStatus} reports</p>
<select className="input w-auto text-sm" value={reportsStatus} onChange={(e) => { setReportsStatus(e.target.value); setReportsSelected(new Set()); }}>
<option value="pending">Pending</option>
<option value="reviewed">Reviewed</option>
<option value="actioned">Actioned</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
{reportsStatus === 'pending' && (
<SelectionBar
count={reportsSelected.size}
total={reports.length}
onSelectAll={() => setReportsSelected(new Set(reports.map((r) => r.id)))}
onClearSelection={() => setReportsSelected(new Set())}
loading={reportsBulkLoading}
actions={[
{ label: 'Action All', action: 'actioned', color: 'bg-green-50 text-green-700 hover:bg-green-100', icon: <CheckCircle className="w-3.5 h-3.5" /> },
{ label: 'Dismiss All', action: 'dismissed', color: 'bg-gray-100 text-gray-700 hover:bg-gray-200', icon: <XCircle className="w-3.5 h-3.5" /> },
]}
onAction={handleReportsBulk}
/>
)}
{reportsLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <div key={i} className="card p-6 animate-pulse"><div className="h-16 bg-warm-300 rounded" /></div>)}
</div>
) : reports.length === 0 ? (
<div className="card p-12 text-center">
<Flag className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {reportsStatus} reports</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
{reportsStatus === 'pending' && (
<th className="table-header w-10">
<input type="checkbox" className="rounded border-gray-300"
checked={reports.length > 0 && reportsSelected.size === reports.length}
onChange={() => {
if (reportsSelected.size === reports.length) setReportsSelected(new Set());
else setReportsSelected(new Set(reports.map((r) => r.id)));
}} />
</th>
)}
<th className="table-header">Reporter</th>
<th className="table-header">Target</th>
<th className="table-header">Type</th>
<th className="table-header">Description</th>
<th className="table-header">Content</th>
<th className="table-header">Status</th>
<th className="table-header">Date</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{reports.map((report) => (
<tr key={report.id} className={`hover:bg-warm-50 transition-colors ${reportsSelected.has(report.id) ? 'bg-brand-50' : ''}`}>
{reportsStatus === 'pending' && (
<td className="table-cell">
<input type="checkbox" className="rounded border-gray-300" checked={reportsSelected.has(report.id)}
onChange={() => setReportsSelected((prev) => { const s = new Set(prev); s.has(report.id) ? s.delete(report.id) : s.add(report.id); return s; })} />
</td>
)}
<td className="table-cell">
<Link href={`/users/${report.reporter_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
@{report.reporter_handle || '—'}
</Link>
</td>
<td className="table-cell">
<Link href={`/users/${report.target_user_id}`} className="text-brand-500 hover:text-brand-700 text-sm">
@{report.target_handle || '—'}
</Link>
</td>
<td className="table-cell">
<span className="badge bg-orange-50 text-orange-700">
<AlertTriangle className="w-3 h-3 mr-1" />{report.violation_type}
</span>
</td>
<td className="table-cell max-w-xs">
<p className="text-sm text-gray-700 line-clamp-2">{report.description}</p>
</td>
<td className="table-cell text-xs text-gray-500">
{report.post_id && <Link href={`/posts/${report.post_id}`} className="text-brand-500 hover:text-brand-700">View Post</Link>}
</td>
<td className="table-cell">
<span className={`badge ${statusColor(report.status)}`}>{report.status}</span>
</td>
<td className="table-cell text-xs text-gray-500">{formatDateTime(report.created_at)}</td>
<td className="table-cell">
{report.status === 'pending' && (
<div className="flex gap-1">
<button onClick={() => handleReportUpdate(report.id, 'actioned')}
className="p-1.5 bg-green-50 text-green-700 rounded hover:bg-green-100" title="Action taken">
<CheckCircle className="w-4 h-4" />
</button>
<button onClick={() => handleReportUpdate(report.id, 'dismissed')}
className="p-1.5 bg-gray-50 text-gray-600 rounded hover:bg-gray-100" title="Dismiss">
<XCircle className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,410 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import { Mail, Send, ChevronLeft, Save, Eye, EyeOff, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
interface EmailTemplate {
id: string;
slug: string;
name: string;
description: string;
subject: string;
title: string;
header: string;
content: string;
button_text: string;
button_url: string;
button_color: string;
footer: string;
text_body: string;
enabled: boolean;
updated_at: string;
created_at: string;
}
function TemplateCard({ template, onSelect }: { template: EmailTemplate; onSelect: () => void }) {
return (
<button
onClick={onSelect}
className="card p-5 text-left hover:ring-2 hover:ring-brand-300 transition-all w-full"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Mail className="w-4 h-4 text-brand-500 flex-shrink-0" />
<h3 className="font-semibold text-gray-900 truncate">{template.name}</h3>
</div>
<p className="text-xs text-gray-500 mb-2 line-clamp-2">{template.description}</p>
<p className="text-xs font-mono text-gray-400 truncate">Subject: {template.subject}</p>
</div>
<div className="flex-shrink-0 ml-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${template.enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{template.enabled ? 'Active' : 'Disabled'}
</span>
</div>
</div>
<p className="text-xs text-gray-400 mt-2">
Last updated: {new Date(template.updated_at).toLocaleDateString()}
</p>
</button>
);
}
function TemplateEditor({ template, onBack, onSaved }: { template: EmailTemplate; onBack: () => void; onSaved: () => void }) {
const [form, setForm] = useState({ ...template });
const [saving, setSaving] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [sending, setSending] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showPreview, setShowPreview] = useState(false);
const handleSave = async () => {
setSaving(true);
setMessage(null);
try {
await api.updateEmailTemplate(template.id, {
subject: form.subject,
title: form.title,
header: form.header,
content: form.content,
button_text: form.button_text,
button_url: form.button_url,
button_color: form.button_color,
footer: form.footer,
text_body: form.text_body,
enabled: form.enabled,
});
setMessage({ type: 'success', text: 'Template saved successfully' });
onSaved();
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Failed to save' });
} finally {
setSaving(false);
}
};
const handleSendTest = async () => {
if (!testEmail) return;
setSending(true);
setMessage(null);
try {
await api.sendTestEmail(template.id, testEmail);
setMessage({ type: 'success', text: `Test email sent to ${testEmail}` });
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Failed to send test email' });
} finally {
setSending(false);
}
};
return (
<div>
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to templates
</button>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-gray-900">{template.name}</h2>
<p className="text-sm text-gray-500">{template.description}</p>
<p className="text-xs font-mono text-gray-400 mt-1">slug: {template.slug}</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowPreview(!showPreview)}
className="btn-secondary text-sm flex items-center gap-1"
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPreview ? 'Hide Preview' : 'Preview'}
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary text-sm flex items-center gap-1"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
{message && (
<div className={`mb-4 p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
{message.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor */}
<div className="space-y-4">
{/* Enabled toggle */}
<div className="card p-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm font-medium text-gray-700">Email enabled</span>
</label>
</div>
{/* Subject */}
<div className="card p-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Subject Line</label>
<input
className="input"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
/>
<p className="text-xs text-gray-400 mt-1">Supports placeholders: {'{{name}}, {{reason}}, {{content_type}}, etc.'}</p>
</div>
{/* Title & Header */}
<div className="card p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email Title</label>
<input
className="input"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
/>
<p className="text-xs text-gray-400 mt-1">Shown in the colored header banner</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Header Text</label>
<input
className="input"
value={form.header}
onChange={(e) => setForm({ ...form, header: e.target.value })}
/>
<p className="text-xs text-gray-400 mt-1">Large heading inside the email body</p>
</div>
</div>
{/* Content (HTML) */}
<div className="card p-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Content (HTML)</label>
<textarea
className="input font-mono text-xs"
rows={10}
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
/>
<p className="text-xs text-gray-400 mt-1">HTML content for the email body. Use {'{{name}}, {{reason}}, {{verify_url}}'}, etc.</p>
</div>
{/* Button */}
<div className="card p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text</label>
<input
className="input"
value={form.button_text}
onChange={(e) => setForm({ ...form, button_text: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Button Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.button_color}
onChange={(e) => setForm({ ...form, button_color: e.target.value })}
className="w-10 h-10 rounded border border-gray-300 cursor-pointer"
/>
<input
className="input flex-1"
value={form.button_color}
onChange={(e) => setForm({ ...form, button_color: e.target.value })}
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Button URL</label>
<input
className="input"
value={form.button_url}
onChange={(e) => setForm({ ...form, button_url: e.target.value })}
/>
</div>
</div>
{/* Footer */}
<div className="card p-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Footer (HTML, optional)</label>
<textarea
className="input font-mono text-xs"
rows={3}
value={form.footer}
onChange={(e) => setForm({ ...form, footer: e.target.value })}
/>
</div>
{/* Plain text fallback */}
<div className="card p-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Plain Text Fallback</label>
<textarea
className="input font-mono text-xs"
rows={4}
value={form.text_body}
onChange={(e) => setForm({ ...form, text_body: e.target.value })}
/>
</div>
{/* Send Test */}
<div className="card p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Send Test Email</label>
<div className="flex gap-2">
<input
className="input flex-1"
type="email"
placeholder="test@example.com"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
/>
<button
onClick={handleSendTest}
disabled={sending || !testEmail}
className="btn-primary text-sm flex items-center gap-1 whitespace-nowrap"
>
<Send className="w-4 h-4" />
{sending ? 'Sending...' : 'Send Test'}
</button>
</div>
<p className="text-xs text-gray-400 mt-1">Sends the saved version with sample placeholder data</p>
</div>
</div>
{/* Preview */}
{showPreview && (
<div className="card p-0 overflow-hidden sticky top-6 self-start">
<div className="bg-gray-100 px-4 py-2 border-b border-gray-200">
<p className="text-xs font-medium text-gray-500">Email Preview</p>
</div>
<div className="p-4">
<div className="mb-3 pb-3 border-b border-gray-100">
<p className="text-xs text-gray-400">Subject</p>
<p className="text-sm font-medium text-gray-900">{form.subject}</p>
</div>
<div
className="bg-white rounded-lg overflow-hidden border border-gray-200"
style={{ maxHeight: '600px', overflowY: 'auto' }}
>
{/* Simulated email header */}
<div className="text-center p-8" style={{ backgroundColor: '#4338CA' }}>
<img src="https://mp.ls/img/sojornlogo.png" alt="Sojorn" className="w-16 h-16 rounded-2xl mx-auto mb-3" />
<p className="text-white text-xs font-semibold tracking-wider uppercase">{form.title}</p>
</div>
{/* Content */}
<div className="p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 text-center">{form.header}</h2>
<div
className="text-sm text-gray-600 leading-relaxed mb-6 [&_p]:mb-3 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mb-1 [&_a]:text-indigo-600 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: form.content }}
/>
{form.button_text && (
<div className="text-center mb-4">
<span
className="inline-block px-8 py-3 text-white font-semibold rounded-xl text-sm"
style={{ backgroundColor: form.button_color }}
>
{form.button_text}
</span>
</div>
)}
{form.footer && (
<div
className="text-xs text-gray-400 [&_p]:mb-2"
dangerouslySetInnerHTML={{ __html: form.footer }}
/>
)}
</div>
{/* Footer */}
<div className="bg-gray-50 border-t border-gray-200 p-4 text-center">
<p className="text-xs text-gray-400">&copy; 2026 Sojorn by MPLS LLC. All rights reserved.</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function EmailSettingsPage() {
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<EmailTemplate | null>(null);
const loadTemplates = () => {
api.listEmailTemplates()
.then((data) => setTemplates(data.templates || []))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { loadTemplates(); }, []);
const handleSelect = (t: EmailTemplate) => {
api.getEmailTemplate(t.id)
.then((full) => setSelected(full))
.catch(() => setSelected(t));
};
return (
<AdminShell>
{selected ? (
<TemplateEditor
template={selected}
onBack={() => { setSelected(null); loadTemplates(); }}
onSaved={loadTemplates}
/>
) : (
<>
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<Link href="/settings" className="text-gray-400 hover:text-gray-600 transition-colors">
<ChevronLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl font-bold text-gray-900">Email Templates</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
Manage the email templates sent for different app operations. Edit content, toggle emails on/off, and send test emails.
</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="card p-5 animate-pulse">
<div className="h-4 bg-warm-300 rounded w-32 mb-3" />
<div className="h-3 bg-warm-300 rounded w-48 mb-2" />
<div className="h-3 bg-warm-300 rounded w-40" />
</div>
))}
</div>
) : templates.length === 0 ? (
<div className="card p-8 text-center text-gray-500">
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No email templates found. Run the database migration to seed default templates.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map((t) => (
<TemplateCard key={t.id} template={t} onSelect={() => handleSelect(t)} />
))}
</div>
)}
</>
)}
</AdminShell>
);
}

View file

@ -0,0 +1,54 @@
'use client';
import { useEffect, useRef } from 'react';
interface AltchaProps {
challengeurl: string;
onVerified?: (payload: string) => void;
onError?: () => void;
}
export default function Altcha({ challengeurl, onVerified, onError }: AltchaProps) {
const containerRef = useRef<HTMLDivElement>(null);
const callbacksRef = useRef({ onVerified, onError });
callbacksRef.current = { onVerified, onError };
useEffect(() => {
// Load script if not already loaded
if (!document.querySelector('script[data-altcha]')) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/altcha@2.3.0/dist/altcha.min.js';
script.type = 'module';
script.async = true;
script.setAttribute('data-altcha', 'true');
document.head.appendChild(script);
}
const container = containerRef.current;
if (!container) return;
// Create the widget element
container.innerHTML = `<altcha-widget challengeurl="${challengeurl}"></altcha-widget>`;
const widget = container.querySelector('altcha-widget');
if (!widget) return;
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log('[ALTCHA] statechange:', detail);
if (detail?.state === 'verified' && detail?.payload) {
callbacksRef.current.onVerified?.(detail.payload);
} else if (detail?.state === 'error') {
callbacksRef.current.onError?.();
}
};
widget.addEventListener('statechange', handler);
return () => {
widget.removeEventListener('statechange', handler);
};
}, [challengeurl]);
return <div ref={containerRef} />;
}

View file

@ -8,7 +8,7 @@ import {
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag, LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown, Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
UserCog, ShieldAlert, Cog, UserCog, ShieldAlert, Cog, Mail, MapPinned,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
@ -29,6 +29,7 @@ const navigation: NavEntry[] = [
{ href: '/users', label: 'Users', icon: Users }, { href: '/users', label: 'Users', icon: Users },
{ href: '/posts', label: 'Posts', icon: FileText }, { href: '/posts', label: 'Posts', icon: FileText },
{ href: '/categories', label: 'Categories', icon: FolderTree }, { href: '/categories', label: 'Categories', icon: FolderTree },
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot }, { href: '/official-accounts', label: 'Official Accounts', icon: Bot },
], ],
}, },
@ -36,6 +37,7 @@ const navigation: NavEntry[] = [
label: 'Moderation & Safety', label: 'Moderation & Safety',
icon: ShieldAlert, icon: ShieldAlert,
items: [ items: [
{ href: '/safety', label: 'Safety Workspace', icon: ShieldAlert },
{ href: '/moderation', label: 'Moderation Queue', icon: Shield }, { href: '/moderation', label: 'Moderation Queue', icon: Shield },
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain }, { href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText }, { href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
@ -53,6 +55,7 @@ const navigation: NavEntry[] = [
{ href: '/usernames', label: 'Usernames', icon: AtSign }, { href: '/usernames', label: 'Usernames', icon: AtSign },
{ href: '/storage', label: 'Storage', icon: HardDrive }, { href: '/storage', label: 'Storage', icon: HardDrive },
{ href: '/system', label: 'System Health', icon: Activity }, { href: '/system', label: 'System Health', icon: Activity },
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
{ href: '/settings', label: 'Settings', icon: Settings }, { href: '/settings', label: 'Settings', icon: Settings },
], ],
}, },

View file

@ -52,9 +52,9 @@ class ApiClient {
} }
// Auth // Auth
async login(email: string, password: string, turnstileToken?: string) { async login(email: string, password: string, altchaToken?: string) {
const body: Record<string, string> = { email, password }; const body: Record<string, string> = { email, password };
if (turnstileToken) body.turnstile_token = turnstileToken; if (altchaToken) body.altcha_token = altchaToken;
const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', { const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
@ -263,6 +263,64 @@ class ApiClient {
}); });
} }
// Neighborhoods
async listNeighborhoods(params: {
limit?: number;
offset?: number;
search?: string;
zip?: string;
sort?: 'name' | 'zip' | 'members' | 'created';
order?: 'asc' | 'desc';
} = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.search) qs.set('search', params.search);
if (params.zip) qs.set('zip', params.zip);
if (params.sort) qs.set('sort', params.sort);
if (params.order) qs.set('order', params.order);
return this.request<any>(`/api/v1/admin/neighborhoods?${qs}`);
}
async setNeighborhoodAdmin(id: string, userId: string, action: 'assign' | 'remove') {
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/admins`, {
method: 'POST',
body: JSON.stringify({ user_id: userId, action }),
});
}
async listNeighborhoodAdmins(id: string) {
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/admins`);
}
async listNeighborhoodBoardEntries(id: string, params: {
limit?: number;
offset?: number;
search?: string;
active?: 'true' | 'false';
} = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.search) qs.set('search', params.search);
if (params.active) qs.set('active', params.active);
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/board?${qs}`);
}
async updateNeighborhoodBoardEntry(id: string, entryId: string, isActive: boolean) {
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/board/${entryId}`, {
method: 'PATCH',
body: JSON.stringify({ is_active: isActive }),
});
}
async pinNeighborhoodBoardEntry(id: string, entryId: string, isPinned: boolean) {
return this.request<any>(`/api/v1/admin/neighborhoods/${id}/board/${entryId}`, {
method: 'PATCH',
body: JSON.stringify({ is_pinned: isPinned }),
});
}
// System // System
async getSystemHealth() { async getSystemHealth() {
return this.request<any>('/api/v1/admin/health'); return this.request<any>('/api/v1/admin/health');
@ -345,6 +403,11 @@ class ApiClient {
}); });
} }
// AI Engines
async getAIEngines() {
return this.request<any>('/api/v1/admin/ai-engines');
}
// AI Moderation // AI Moderation
async listOpenRouterModels(params: { capability?: string; search?: string } = {}) { async listOpenRouterModels(params: { capability?: string; search?: string } = {}) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
@ -353,24 +416,52 @@ class ApiClient {
return this.request<any>(`/api/v1/admin/ai/models?${qs}`); return this.request<any>(`/api/v1/admin/ai/models?${qs}`);
} }
async listLocalModels() {
return this.request<any>('/api/v1/admin/ai/models/local');
}
async getAIModerationConfigs() { async getAIModerationConfigs() {
return this.request<any>('/api/v1/admin/ai/config'); return this.request<any>('/api/v1/admin/ai/config');
} }
async setAIModerationConfig(data: { moderation_type: string; model_id: string; model_name: string; system_prompt: string; enabled: boolean }) { async setAIModerationConfig(data: { moderation_type: string; model_id: string; model_name: string; system_prompt: string; enabled: boolean; engines?: string[] }) {
return this.request<any>('/api/v1/admin/ai/config', { return this.request<any>('/api/v1/admin/ai/config', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
async testAIModeration(data: { moderation_type: string; content?: string; image_url?: string }) { async testAIModeration(data: { moderation_type: string; content?: string; image_url?: string; engine?: string }) {
return this.request<any>('/api/v1/admin/ai/test', { return this.request<any>('/api/v1/admin/ai/test', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
async uploadTestImage(file: File) {
const formData = new FormData();
formData.append('file', file);
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}/api/v1/admin/upload-test-image`, {
method: 'POST',
body: formData,
headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Upload failed: ${response.status} - ${errorText}`);
}
return response.json();
}
// AI Moderation Audit Log // AI Moderation Audit Log
async getAIModerationLog(params: { limit?: number; offset?: number; decision?: string; content_type?: string; search?: string; feedback?: string } = {}) { async getAIModerationLog(params: { limit?: number; offset?: number; decision?: string; content_type?: string; search?: string; feedback?: string } = {}) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
@ -460,6 +551,25 @@ class ApiClient {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`); return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`);
} }
async skipArticle(articleId: string) {
return this.request<any>(`/api/v1/admin/official-accounts/articles/${articleId}/skip`, { method: 'POST' });
}
async deleteArticle(articleId: string) {
return this.request<any>(`/api/v1/admin/official-accounts/articles/${articleId}`, { method: 'DELETE' });
}
async postSpecificArticle(articleId: string) {
return this.request<any>(`/api/v1/admin/official-accounts/articles/${articleId}/post`, { method: 'POST' });
}
async cleanupPendingArticles(configId: string, before: string, action: 'skip' | 'delete') {
return this.request<any>(`/api/v1/admin/official-accounts/${configId}/articles/cleanup`, {
method: 'POST',
body: JSON.stringify({ before, action }),
});
}
async adminImportContent(data: { async adminImportContent(data: {
author_id: string; author_id: string;
content_type: string; content_type: string;
@ -500,6 +610,29 @@ class ApiClient {
async checkURLSafety(url: string) { async checkURLSafety(url: string) {
return this.request<any>(`/api/v1/admin/safe-domains/check?url=${encodeURIComponent(url)}`); return this.request<any>(`/api/v1/admin/safe-domains/check?url=${encodeURIComponent(url)}`);
} }
// Email Templates
async listEmailTemplates() {
return this.request<any>('/api/v1/admin/email-templates');
}
async getEmailTemplate(id: string) {
return this.request<any>(`/api/v1/admin/email-templates/${id}`);
}
async updateEmailTemplate(id: string, data: Record<string, any>) {
return this.request<any>(`/api/v1/admin/email-templates/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async sendTestEmail(templateId: string, toEmail: string) {
return this.request<any>('/api/v1/admin/email-templates/test', {
method: 'POST',
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
});
}
} }
export const api = new ApiClient(); export const api = new ApiClient();

9
admin/src/types/altcha.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
declare namespace JSX {
interface IntrinsicElements {
'altcha-widget': {
challengeurl?: string;
hidefooter?: string;
hidelogo?: string;
};
}
}

View file

@ -0,0 +1,341 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"ai-gateway/internal/config"
"ai-gateway/internal/ollama"
"ai-gateway/internal/queue"
"github.com/google/uuid"
)
type Handler struct {
cfg *config.Config
q *queue.Queue
ollama *ollama.Client
}
func New(cfg *config.Config, q *queue.Queue, oc *ollama.Client) *Handler {
return &Handler{cfg: cfg, q: q, ollama: oc}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/healthz" && r.Method == "GET":
h.healthz(w, r)
case path == "/readyz" && r.Method == "GET":
h.readyz(w, r)
case path == "/v1/moderate" && r.Method == "POST":
h.authMiddleware(h.moderate)(w, r)
case path == "/v1/generate" && r.Method == "POST":
h.authMiddleware(h.generate)(w, r)
case strings.HasPrefix(path, "/v1/jobs/") && r.Method == "GET":
h.authMiddleware(h.getJob)(w, r)
default:
jsonError(w, http.StatusNotFound, "not found")
}
}
func (h *Handler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if h.cfg.InternalToken == "" {
next(w, r)
return
}
token := r.Header.Get("X-Internal-Token")
if token != h.cfg.InternalToken {
jsonError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func (h *Handler) healthz(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, map[string]any{"status": "ok", "time": time.Now().UTC()})
}
func (h *Handler) readyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
status := map[string]any{"time": time.Now().UTC()}
ready := true
if err := h.q.Ping(ctx); err != nil {
status["redis"] = "down"
ready = false
} else {
status["redis"] = "ok"
}
if err := h.ollama.Healthz(ctx); err != nil {
status["ollama"] = "down"
} else {
status["ollama"] = "ok"
}
status["ollama_circuit"] = h.ollama.IsAvailable()
writerLen, _ := h.q.QueueLen(ctx, "writer")
judgeLen, _ := h.q.QueueLen(ctx, "judge")
status["queue_writer"] = writerLen
status["queue_judge"] = judgeLen
if ready {
status["status"] = "ready"
jsonOK(w, status)
} else {
status["status"] = "not_ready"
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(status)
}
}
type ModerateRequest struct {
Text string `json:"text"`
Context map[string]any `json:"context,omitempty"`
}
func (h *Handler) moderate(w http.ResponseWriter, r *http.Request) {
if h.cfg.AIDisabled {
jsonOK(w, map[string]any{"allowed": true, "reason": "ai_disabled", "cached": false})
return
}
var req ModerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Text == "" {
jsonError(w, http.StatusBadRequest, "text required")
return
}
// Rate limit
ok, err := h.q.CheckRate(r.Context(), "global", "moderate", h.cfg.ModerateRateLimit)
if err != nil || !ok {
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
// Check cache
if cached, err := h.q.GetModCache(r.Context(), req.Text); err == nil {
var result map[string]any
if json.Unmarshal(cached, &result) == nil {
result["cached"] = true
jsonOK(w, result)
return
}
}
// Synchronous fast path for short texts
if len(req.Text) <= h.cfg.SyncMaxChars && h.ollama.IsAvailable() {
result, err := h.runJudge(r.Context(), req.Text)
if err != nil {
log.Printf("[moderate] sync judge error: %v", err)
// Fail open
jsonOK(w, map[string]any{"allowed": true, "reason": "judge_error", "error": err.Error()})
return
}
if data, err := json.Marshal(result); err == nil {
h.q.SetModCache(r.Context(), req.Text, data)
}
result["cached"] = false
jsonOK(w, result)
return
}
// Async path for long texts
jobID := uuid.New().String()
input, _ := json.Marshal(req)
job := &queue.Job{
ID: jobID,
Type: "judge",
CreatedAt: time.Now().UTC(),
Input: input,
Status: "queued",
}
if err := h.q.Enqueue(r.Context(), job); err != nil {
jsonError(w, http.StatusInternalServerError, "queue error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]any{"job_id": jobID, "status": "queued"})
}
// guardCategories maps LLaMA Guard 3 S-codes to human-readable category names.
var guardCategories = map[string]string{
"S1": "violent_crimes",
"S2": "non_violent_crimes",
"S3": "sex_related_crimes",
"S4": "child_sexual_exploitation",
"S5": "defamation",
"S6": "specialized_advice",
"S7": "privacy",
"S8": "intellectual_property",
"S9": "indiscriminate_weapons",
"S10": "hate",
"S11": "suicide_self_harm",
"S12": "sexual_content",
"S13": "elections",
"S14": "code_interpreter_abuse",
}
// highSeverityCodes are categories that should always be severity "high".
var highSeverityCodes = map[string]bool{"S1": true, "S3": true, "S4": true, "S9": true}
// parseGuardOutput parses LLaMA Guard 3's native output format.
// Safe output: "safe"
// Unsafe output: "unsafe\nS1,S4" or "unsafe\nS1"
func parseGuardOutput(raw string) map[string]any {
content := strings.TrimSpace(raw)
lower := strings.ToLower(content)
if lower == "safe" || strings.HasPrefix(lower, "safe\n") || strings.HasPrefix(lower, "safe ") {
return map[string]any{"allowed": true, "categories": []string{}, "severity": "low", "reason": ""}
}
// Parse "unsafe\nS1,S2,..."
categories := []string{}
codes := []string{}
severity := "medium"
lines := strings.Split(content, "\n")
if len(lines) > 1 {
// Second line has comma-separated S-codes
parts := strings.Split(strings.TrimSpace(lines[1]), ",")
for _, p := range parts {
code := strings.TrimSpace(p)
if code == "" {
continue
}
codes = append(codes, code)
if name, ok := guardCategories[code]; ok {
categories = append(categories, name)
} else {
categories = append(categories, code)
}
if highSeverityCodes[code] {
severity = "high"
}
}
}
if len(categories) == 0 {
categories = []string{"policy_violation"}
}
return map[string]any{
"allowed": false,
"categories": categories,
"codes": codes,
"severity": severity,
"reason": strings.Join(categories, ", "),
}
}
func (h *Handler) runJudge(ctx context.Context, text string) (map[string]any, error) {
resp, err := h.ollama.Chat(ctx, &ollama.ChatRequest{
Model: "llama-guard3:1b",
Messages: []ollama.ChatMessage{
{Role: "user", Content: text},
},
Stream: false,
Options: &ollama.ModelOptions{
Temperature: 0.0,
NumPredict: 64,
},
})
if err != nil {
return nil, err
}
return parseGuardOutput(resp.Message.Content), nil
}
type GenerateRequest struct {
Task string `json:"task"`
Input map[string]any `json:"input"`
}
func (h *Handler) generate(w http.ResponseWriter, r *http.Request) {
if h.cfg.AIDisabled {
jsonError(w, http.StatusServiceUnavailable, "ai_disabled")
return
}
var req GenerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Task == "" {
jsonError(w, http.StatusBadRequest, "task required")
return
}
ok, err := h.q.CheckRate(r.Context(), "global", "generate", h.cfg.GenerateRateLimit)
if err != nil || !ok {
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
jobID := uuid.New().String()
input, _ := json.Marshal(req)
job := &queue.Job{
ID: jobID,
Type: "writer",
CreatedAt: time.Now().UTC(),
Input: input,
Status: "queued",
}
if err := h.q.Enqueue(r.Context(), job); err != nil {
jsonError(w, http.StatusInternalServerError, "queue error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]any{"job_id": jobID, "status": "queued"})
}
func (h *Handler) getJob(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
if jobID == "" {
jsonError(w, http.StatusBadRequest, "job_id required")
return
}
job, err := h.q.GetJob(r.Context(), jobID)
if err != nil {
jsonError(w, http.StatusNotFound, "job not found")
return
}
jsonOK(w, job)
}
func jsonOK(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func init() {
// Ensure fmt is used (prevent import error in case)
_ = fmt.Sprintf
}

View file

@ -0,0 +1,176 @@
package worker
import (
"context"
"encoding/json"
"log"
"strings"
"time"
"ai-gateway/internal/ollama"
"ai-gateway/internal/queue"
)
type JudgeWorker struct {
q *queue.Queue
ollama *ollama.Client
concurrency int
}
func NewJudge(q *queue.Queue, oc *ollama.Client, concurrency int) *JudgeWorker {
return &JudgeWorker{q: q, ollama: oc, concurrency: concurrency}
}
func (w *JudgeWorker) Run(ctx context.Context) {
for i := 0; i < w.concurrency; i++ {
go w.loop(ctx, i)
}
<-ctx.Done()
}
func (w *JudgeWorker) loop(ctx context.Context, workerID int) {
log.Printf("[judge-worker-%d] started", workerID)
for {
select {
case <-ctx.Done():
log.Printf("[judge-worker-%d] shutting down", workerID)
return
default:
}
job, err := w.q.Dequeue(ctx, "judge", 5*time.Second)
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
log.Printf("[judge-worker-%d] processing job %s", workerID, job.ID)
w.process(ctx, job)
}
}
func (w *JudgeWorker) process(ctx context.Context, job *queue.Job) {
job.Status = "running"
w.q.UpdateJob(ctx, job)
var req struct {
Text string `json:"text"`
Context map[string]any `json:"context,omitempty"`
}
if err := json.Unmarshal(job.Input, &req); err != nil {
job.Status = "failed"
job.Error = "invalid input: " + err.Error()
w.q.UpdateJob(ctx, job)
return
}
timeoutCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
result, err := w.judge(timeoutCtx, req.Text)
if err != nil {
job.Status = "failed"
job.Error = err.Error()
w.q.UpdateJob(ctx, job)
log.Printf("[judge-worker] job %s failed: %v", job.ID, err)
return
}
resultJSON, _ := json.Marshal(result)
job.Status = "succeeded"
job.Result = resultJSON
w.q.UpdateJob(ctx, job)
// Cache result
if data, err := json.Marshal(result); err == nil {
w.q.SetModCache(ctx, req.Text, data)
}
log.Printf("[judge-worker] job %s succeeded", job.ID)
}
// guardCategories maps LLaMA Guard 3 S-codes to human-readable category names.
var guardCategories = map[string]string{
"S1": "violent_crimes",
"S2": "non_violent_crimes",
"S3": "sex_related_crimes",
"S4": "child_sexual_exploitation",
"S5": "defamation",
"S6": "specialized_advice",
"S7": "privacy",
"S8": "intellectual_property",
"S9": "indiscriminate_weapons",
"S10": "hate",
"S11": "suicide_self_harm",
"S12": "sexual_content",
"S13": "elections",
"S14": "code_interpreter_abuse",
}
var highSeverityCodes = map[string]bool{"S1": true, "S3": true, "S4": true, "S9": true}
func parseGuardOutput(raw string) map[string]any {
content := strings.TrimSpace(raw)
lower := strings.ToLower(content)
if lower == "safe" || strings.HasPrefix(lower, "safe\n") || strings.HasPrefix(lower, "safe ") {
return map[string]any{"allowed": true, "categories": []string{}, "severity": "low", "reason": ""}
}
categories := []string{}
codes := []string{}
severity := "medium"
lines := strings.Split(content, "\n")
if len(lines) > 1 {
parts := strings.Split(strings.TrimSpace(lines[1]), ",")
for _, p := range parts {
code := strings.TrimSpace(p)
if code == "" {
continue
}
codes = append(codes, code)
if name, ok := guardCategories[code]; ok {
categories = append(categories, name)
} else {
categories = append(categories, code)
}
if highSeverityCodes[code] {
severity = "high"
}
}
}
if len(categories) == 0 {
categories = []string{"policy_violation"}
}
return map[string]any{
"allowed": false,
"categories": categories,
"codes": codes,
"severity": severity,
"reason": strings.Join(categories, ", "),
}
}
func (w *JudgeWorker) judge(ctx context.Context, text string) (map[string]any, error) {
resp, err := w.ollama.Chat(ctx, &ollama.ChatRequest{
Model: "llama-guard3:1b",
Messages: []ollama.ChatMessage{
{Role: "user", Content: text},
},
Stream: false,
Options: &ollama.ModelOptions{
Temperature: 0.0,
NumPredict: 64,
},
})
if err != nil {
return nil, err
}
return parseGuardOutput(resp.Message.Content), nil
}

39
beta.sojorn.net.nginx Normal file
View file

@ -0,0 +1,39 @@
server {
listen 80;
listen [::]:80;
server_name beta.sojorn.net;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name beta.sojorn.net;
ssl_certificate /etc/letsencrypt/live/beta.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/beta.sojorn.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/beta.sojorn.net;
index index.html;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

View file

@ -1,39 +0,0 @@
# Cloud Backup Implementation Plan (Complete)
## 1. Frontend Implementation (Flutter)
- [x] **ApiService Updates**: Added `uploadBackup` and `downloadBackup` methods to interact with the backend (endpoints `/backups/upload` and `/backups/download`).
- [x] **LocalKeyBackupService Refactor**:
- [x] Updated `createEncryptedBackup` to accept `includeKeys` and `includeMessages` flags.
- [x] Added `uploadToCloud` method which **defaults to Messages Only** (no keys) for security.
- [x] Added `restoreFromCloud` method to fetch and decrypt backups.
- [x] **UI Overhaul (LocalBackupScreen)**:
- [x] Added "Cloud Mode" vs "Local Mode" toggle.
- [x] Implemented "Zero Knowledge" warning UI when Cloud Mode is active (keys excluded by default).
- [x] Added visual cues for "Secure Mode".
- [x] Integrated `uploadToCloud` and `restoreFromCloud` calls with progress indicators and error handling.
## 2. Backend Implementation (Go)
- [x] **Database Schema**: Created migration `000003_e2ee_backup_recovery.up.sql` for:
- `cloud_backups` table (stores encrypted blobs).
- `backup_preferences` table.
- `user_devices` table.
- `sync_codes` table.
- `recovery_guardians` and `recovery_sessions` tables (for future social recovery).
- [x] **API Endpoints**:
- `POST /backups/upload`: Accepts encrypted blob, metadata, and version.
- `GET /backups/download`: Retrieves latest backup.
- `GET /backups/download/:backup_id`: Retrieves specific backup.
- [x] **Data Models**: Defined `CloudBackup`, `UploadBackupRequest`, `DownloadBackupResponse` structs matching frontend expectations.
- [x] **Handler Logic**: Implemented "blind storage" logic - backend stores opaque blobs and does not attempt decryption.
## 3. Deployment Status (Pending)
- [x] **Compilation**: Successfully compiled `sojorn-api-linux` and `migrate-linux` binaries locally.
- [ ] **Upload**: Failed to upload binaries to VPS (`194.238.28.122`) due to SSH authentication failure ("Permission denied") with provided credentials.
- [ ] **Migration**: Database migration failed from local machine due to port 5432 being closed/filtered. Needs to be run from the VPS.
- [ ] **Restart**: Service restart pending successful SSH access.
## 4. Next Steps
Once SSH access is restored (verify password or add public key):
1. **Upload Binaries**: `scp sojorn-api-linux migrate-linux root@194.238.28.122:/root/`
2. **Run Migration**: `ssh root@... "./migrate-linux -path ... up"`
3. **Restart Service**: `ssh root@... "systemctl restart sojorn-api"`

View file

@ -1,12 +0,0 @@
@echo off
echo Deploying create-beacon edge function to Supabase...
echo.
supabase functions deploy create-beacon --no-verify-jwt
echo.
echo Deployment complete!
echo.
echo The beacon feature should now work properly.
echo Test by opening the Beacon tab in the app and creating a beacon.
pause

88
deploy/sojorn_net.conf Normal file
View file

@ -0,0 +1,88 @@
# sojorn.net - Pure redirect to mp.ls
server {
server_name sojorn.net www.sojorn.net;
location = /terms {
return 301 https://mp.ls/terms;
}
location = /privacy {
return 301 https://mp.ls/privacy;
}
location / {
return 301 https://mp.ls/sojorn;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
# api.sojorn.net - Backend
server {
server_name api.sojorn.net;
client_max_body_size 100M;
# Auth endpoints - strict rate limit (5 req/min)
location ~ ^/api/v1/(auth|login|register|verify|refresh) {
limit_req zone=auth burst=3 nodelay;
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Upload endpoints - moderate rate limit (10 req/min)
location ~ ^/api/v1/(media|upload) {
limit_req zone=upload burst=5 nodelay;
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All other API endpoints - general rate limit (30 req/s)
location / {
limit_req zone=api burst=50 nodelay;
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = www.sojorn.net) {
return 301 https://$host$request_uri;
}
if ($host = sojorn.net) {
return 301 https://$host$request_uri;
}
server_name sojorn.net www.sojorn.net;
listen 80;
return 404;
}
server {
if ($host = api.sojorn.net) {
return 301 https://$host$request_uri;
}
server_name api.sojorn.net;
listen 80;
return 404;
}

View file

@ -1,62 +0,0 @@
# Deploy all Edge Functions to Supabase
# Run this after updating supabase-js version
Write-Host "=== Deploying All Edge Functions ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "This will deploy all functions with --no-verify-jwt (default for this script)" -ForegroundColor Yellow
Write-Host ""
$functions = @(
"appreciate",
"block",
"deactivate-account",
"delete-account",
"feed-personal",
"feed-sojorn",
"follow",
"manage-post",
"notifications",
"profile",
"profile-posts",
"publish-comment",
"publish-post",
"push-notification",
"report",
"save",
"search",
"sign-media",
"signup",
"tone-check",
"trending",
"upload-image"
)
$totalFunctions = $functions.Count
$currentFunction = 0
$noVerifyJwt = "--no-verify-jwt"
foreach ($func in $functions) {
$currentFunction++
Write-Host "[$currentFunction/$totalFunctions] Deploying $func..." -ForegroundColor Yellow
try {
supabase functions deploy $func $noVerifyJwt 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " OK $func deployed successfully" -ForegroundColor Green
} else {
Write-Host " FAILED to deploy $func" -ForegroundColor Red
}
}
catch {
Write-Host " ERROR deploying $func : $_" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "=== Deployment Complete ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host "1. Restart your Flutter app" -ForegroundColor Yellow
Write-Host "2. Sign in again" -ForegroundColor Yellow
Write-Host "3. The JWT 401 errors should be gone!" -ForegroundColor Green
Write-Host ""

View file

@ -1,35 +0,0 @@
# Feed Reactions Fix - Implementation Summary
## Status: COMPLETE & DEPLOYED ✅
### 1. Issue Description
- **Problem**: Reactions were not displaying on the "Following" (Home) and "Profile" feeds. The "Postcard" UI (specifically the compact method) was defaulting to the "Add Reaction" button because no reaction data was being returned by the API for these lists.
- **Root Cause**: The SQL queries for `GetFeed` and `GetPostsByAuthor` in the backend were not aggregating reaction data (counts and user choices), unlike the single-post endpoint.
### 2. Implementation Details (Backend)
- **File**: `internal/repository/post_repository.go`
- **Changes**:
- Modified `GetFeed` SQL query to include a correlated subquery fetching `jsonb_object_agg(emoji, count)` for `reaction_counts`.
- Modified `GetPostsByAuthor` SQL query to do the same.
- Added logic to fetch `my_reactions` (the current user's votes) for both feeds.
- Updated the Go `Scan` destinations to populate the `Reactions` and `MyReactions` fields in the `Post` model.
- **Correction**: Also fixed a missing `allow_chain` and `visibility` selection in the `GetPostsByAuthor` query, ensuring consistency across the app.
### 3. Frontend logic (Verified)
- **Widget**: `sojornPostCard` -> `PostActions` -> `ReactionsDisplay`
- **Logic**: The `ReactionsDisplay` widget in `compact` mode (used in feeds) is designed to:
1. Show the user's reaction if they voted.
2. Else, show the "Most Used" reaction from the community.
3. Else (if valid data but count is 0), show the "Add" button.
- **Result**: Now that the backend provides the data (Case 1 or 2), the UI will correctly display the reaction chips instead of just the "+" button.
### 4. Deployment
- **Server**: `194.238.28.122`
- **Action**:
- Compiled `sojorn-api-linux` locally.
- Uploaded to `/opt/sojorn/bin/api` via `scp` (user: `patrick`).
- Restarted `sojorn-api` service via `systemctl`.
- **Status**: API is active and serving the new queries.
### 5. Next Steps
- **User Action**: Pull-to-refresh the feed in the Sojorn app to see the changes.

View file

@ -1,45 +0,0 @@
#!/bin/bash
# Fix FCM configuration and restart backend
# Run with: bash fix_fcm_and_restart.sh
echo "=== Fixing FCM Configuration ==="
# Kill old backend process
echo "Killing old backend process on port 8080..."
sudo kill -9 $(sudo lsof -ti:8080) 2>/dev/null || echo "No process to kill"
# Verify Firebase JSON exists
if [ -f "/opt/sojorn/firebase-service-account.json" ]; then
echo "✓ Firebase service account JSON exists"
ls -lh /opt/sojorn/firebase-service-account.json
else
echo "✗ Firebase service account JSON not found!"
exit 1
fi
# Add FIREBASE_CREDENTIALS_FILE to .env if not present
if ! grep -q "FIREBASE_CREDENTIALS_FILE" /opt/sojorn/.env; then
echo "Adding FIREBASE_CREDENTIALS_FILE to .env..."
echo "" | sudo tee -a /opt/sojorn/.env > /dev/null
echo "FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json" | sudo tee -a /opt/sojorn/.env > /dev/null
echo "✓ Added FIREBASE_CREDENTIALS_FILE"
else
echo "✓ FIREBASE_CREDENTIALS_FILE already in .env"
fi
# Restart backend
echo ""
echo "=== Restarting Backend ==="
sudo systemctl restart sojorn-api
sleep 3
# Check status
sudo systemctl status sojorn-api --no-pager | head -20
echo ""
echo "=== Checking FCM Initialization ==="
sudo journalctl -u sojorn-api --since "30 seconds ago" | grep -i "push\|fcm\|firebase" || echo "No FCM logs yet"
echo ""
echo "=== Done! ==="
echo "If you see 'Server started on port 8080' with no errors, FCM is working!"

BIN
go-backend/api Normal file

Binary file not shown.

View file

@ -16,12 +16,12 @@ import (
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"github.com/patbritton/sojorn-backend/internal/handlers" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
"github.com/patbritton/sojorn-backend/internal/middleware" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware"
"github.com/patbritton/sojorn-backend/internal/realtime" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime"
"github.com/patbritton/sojorn-backend/internal/repository" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"github.com/patbritton/sojorn-backend/internal/services" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -111,12 +111,12 @@ func main() {
notificationService := services.NewNotificationService(notifRepo, pushService, userRepo) notificationService := services.NewNotificationService(notifRepo, pushService, userRepo)
emailService := services.NewEmailService(cfg) emailService := services.NewEmailService(cfg, dbPool)
sendPulseService := services.NewSendPulseService(cfg.SendPulseID, cfg.SendPulseSecret) sendPulseService := services.NewSendPulseService(cfg.SendPulseID, cfg.SendPulseSecret)
// Load moderation configuration // Load moderation configuration
moderationConfig := config.NewModerationConfig() moderationConfig := config.NewModerationConfig()
moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey) moderationService := services.NewModerationService(dbPool, moderationConfig.OpenAIKey, moderationConfig.GoogleKey, moderationConfig.GoogleCredsFile)
// Initialize appeal service // Initialize appeal service
appealService := services.NewAppealService(dbPool) appealService := services.NewAppealService(dbPool)
@ -124,9 +124,26 @@ func main() {
// Initialize OpenRouter service // Initialize OpenRouter service
openRouterService := services.NewOpenRouterService(dbPool, cfg.OpenRouterAPIKey) openRouterService := services.NewOpenRouterService(dbPool, cfg.OpenRouterAPIKey)
// Initialize Azure OpenAI service
var azureOpenAIService *services.AzureOpenAIService
if cfg.AzureOpenAIAPIKey != "" && cfg.AzureOpenAIEndpoint != "" {
azureOpenAIService = services.NewAzureOpenAIService(dbPool, cfg.AzureOpenAIAPIKey, cfg.AzureOpenAIEndpoint, cfg.AzureOpenAIAPIVersion)
log.Info().Msg("Azure OpenAI service initialized")
} else {
log.Warn().Msg("Azure OpenAI credentials not provided, Azure OpenAI service disabled")
}
// Initialize content filter (hard blocklist + strike system) // Initialize content filter (hard blocklist + strike system)
contentFilter := services.NewContentFilter(dbPool) contentFilter := services.NewContentFilter(dbPool)
// Initialize local AI gateway service (on-server Ollama via localhost:8099)
localAIService := services.NewLocalAIService(cfg.AIGatewayURL, cfg.AIGatewayToken)
if localAIService != nil {
log.Info().Str("url", cfg.AIGatewayURL).Msg("Local AI gateway configured")
} else {
log.Info().Msg("Local AI gateway not configured (AI_GATEWAY_URL not set)")
}
hub := realtime.NewHub() hub := realtime.NewHub()
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret) wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
@ -152,7 +169,7 @@ func main() {
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain) linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService) userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService) postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService)
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub) chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService) authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
categoryHandler := handlers.NewCategoryHandler(categoryRepo) categoryHandler := handlers.NewCategoryHandler(categoryRepo)
@ -163,14 +180,29 @@ func main() {
appealHandler := handlers.NewAppealHandler(appealService) appealHandler := handlers.NewAppealHandler(appealService)
// Initialize official accounts service // Initialize official accounts service
officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, linkPreviewService) officialAccountsService := services.NewOfficialAccountsService(dbPool, openRouterService, localAIService, linkPreviewService, moderationConfig.OpenAIKey)
officialAccountsService.StartScheduler() officialAccountsService.StartScheduler()
defer officialAccountsService.StopScheduler() defer officialAccountsService.StopScheduler()
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, officialAccountsService, linkPreviewService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg) accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
// Capsule system handlers (E2EE groups)
capsuleHandler := handlers.NewCapsuleHandler(dbPool)
capsuleEscrowHandler := handlers.NewCapsuleEscrowHandler(dbPool)
// Group feature handler (posts, chat, forum, members)
groupHandler := handlers.NewGroupHandler(dbPool)
// Neighborhood board handler (standalone message board)
boardHandler := handlers.NewBoardHandler(dbPool, contentFilter, moderationService)
// Beacon search handler (search beacons, board, public groups)
beaconSearchHandler := handlers.NewBeaconSearchHandler(dbPool)
mediaHandler := handlers.NewMediaHandler( mediaHandler := handlers.NewMediaHandler(
s3Client, s3Client,
cfg.R2AccountID, cfg.R2AccountID,
@ -190,6 +222,13 @@ func main() {
c.Status(200) c.Status(200)
}) })
// ALTCHA challenge endpoints (direct to main router for testing)
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
r.GET("/api/v1/admin/altcha-challenge", adminHandler.GetAltchaChallenge)
r.GET("/api/v1/test", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "Route test successful"})
})
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
// Public waitlist signup (no auth required) // Public waitlist signup (no auth required)
@ -218,7 +257,6 @@ func main() {
} }
auth := v1.Group("/auth") auth := v1.Group("/auth")
auth.Use(middleware.RateLimit(0.5, 3))
{ {
auth.POST("/register", authHandler.Register) auth.POST("/register", authHandler.Register)
auth.POST("/signup", authHandler.Register) auth.POST("/signup", authHandler.Register)
@ -294,6 +332,7 @@ func main() {
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction) authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
authorized.POST("/posts/:id/comments", postHandler.CreateComment) authorized.POST("/posts/:id/comments", postHandler.CreateComment)
authorized.GET("/feed", postHandler.GetFeed) authorized.GET("/feed", postHandler.GetFeed)
authorized.POST("/beacons", postHandler.CreateBeacon)
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons) authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon) authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon)
authorized.POST("/beacons/:id/report", postHandler.ReportBeacon) authorized.POST("/beacons/:id/report", postHandler.ReportBeacon)
@ -302,6 +341,7 @@ func main() {
authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings) authorized.POST("/categories/settings", categoryHandler.SetUserCategorySettings)
authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings) authorized.GET("/categories/settings", categoryHandler.GetUserCategorySettings)
authorized.POST("/analysis/tone", analysisHandler.CheckTone) authorized.POST("/analysis/tone", analysisHandler.CheckTone)
authorized.POST("/moderate", moderationHandler.CheckContent)
// Chat routes // Chat routes
authorized.GET("/conversations", chatHandler.GetConversations) authorized.GET("/conversations", chatHandler.GetConversations)
@ -394,6 +434,85 @@ func main() {
appeals.GET("/:id", appealHandler.GetAppeal) appeals.GET("/:id", appealHandler.GetAppeal)
} }
// Neighborhood board (standalone message board — NOT posts)
board := authorized.Group("/board")
{
board.GET("/nearby", boardHandler.ListNearby)
board.POST("", boardHandler.CreateEntry)
board.GET("/:id", boardHandler.GetEntry)
board.POST("/:id/replies", boardHandler.CreateReply)
board.POST("/vote", boardHandler.ToggleVote)
board.POST("/:id/remove", boardHandler.RemoveEntry)
board.POST("/:id/flag", boardHandler.FlagEntry)
}
// Beacon ecosystem search (beacons, board entries, public groups — never private)
authorized.GET("/beacon/search", beaconSearchHandler.Search)
// Neighborhood system (on-demand OSM detection + auto-join)
neighborhoodHandler := handlers.NewNeighborhoodHandler(dbPool)
neighborhoods := authorized.Group("/neighborhoods")
{
neighborhoods.GET("/detect", neighborhoodHandler.Detect)
neighborhoods.GET("/current", neighborhoodHandler.GetCurrent)
neighborhoods.GET("/search", neighborhoodHandler.SearchByZip)
neighborhoods.POST("/choose", neighborhoodHandler.Choose)
neighborhoods.GET("/mine", neighborhoodHandler.GetMyNeighborhood)
}
// Capsule system (E2EE groups + clusters)
capsules := authorized.Group("/capsules")
{
capsules.GET("/mine", capsuleHandler.ListMyGroups)
capsules.GET("/public", capsuleHandler.ListPublicClusters)
capsules.POST("", capsuleHandler.CreateCapsule)
capsules.POST("/group", capsuleHandler.CreateGroup)
capsules.GET("/:id", capsuleHandler.GetCapsule)
capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry)
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)
capsules.POST("/:id/invite", capsuleHandler.InviteToCapsule)
capsules.POST("/:id/rotate-keys", capsuleHandler.RotateKeys)
// Group features (posts, chat, forum, members)
capsules.GET("/:id/posts", groupHandler.ListGroupPosts)
capsules.POST("/:id/posts", groupHandler.CreateGroupPost)
capsules.POST("/:id/posts/:postId/like", groupHandler.ToggleGroupPostLike)
capsules.GET("/:id/posts/:postId/comments", groupHandler.ListGroupPostComments)
capsules.POST("/:id/posts/:postId/comments", groupHandler.CreateGroupPostComment)
capsules.GET("/:id/messages", groupHandler.ListGroupMessages)
capsules.POST("/:id/messages", groupHandler.SendGroupMessage)
capsules.GET("/:id/threads", groupHandler.ListGroupThreads)
capsules.POST("/:id/threads", groupHandler.CreateGroupThread)
capsules.GET("/:id/threads/:threadId", groupHandler.GetGroupThread)
capsules.POST("/:id/threads/:threadId/replies", groupHandler.CreateGroupThreadReply)
capsules.GET("/:id/members", groupHandler.ListGroupMembers)
capsules.DELETE("/:id/members/:memberId", groupHandler.RemoveGroupMember)
capsules.PATCH("/:id/members/:memberId", groupHandler.UpdateMemberRole)
capsules.POST("/:id/leave", groupHandler.LeaveGroup)
capsules.PATCH("/:id", groupHandler.UpdateGroup)
capsules.DELETE("/:id", groupHandler.DeleteGroup)
capsules.POST("/:id/invite-member", groupHandler.InviteToGroup)
capsules.GET("/:id/search-users", groupHandler.SearchUsersForInvite)
}
// Capsule key management (per-user encrypted key store)
capsuleKeys := authorized.Group("/capsule-keys")
{
capsuleKeys.GET("", capsuleEscrowHandler.GetMyKeys)
capsuleKeys.POST("", capsuleEscrowHandler.StoreKey)
capsuleKeys.GET("/:id", capsuleEscrowHandler.GetMyKeyForGroup)
capsuleKeys.DELETE("/:id", capsuleEscrowHandler.DeleteKey)
}
// Capsule escrow backup (PIN-encrypted private key recovery)
escrow := authorized.Group("/capsule/escrow")
{
escrow.GET("/status", capsuleEscrowHandler.GetBackupStatus)
escrow.POST("/backup", capsuleEscrowHandler.UploadBackup)
escrow.GET("/backup", capsuleEscrowHandler.GetBackup)
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
}
} }
} }
@ -455,6 +574,13 @@ func main() {
admin.POST("/categories", adminHandler.CreateCategory) admin.POST("/categories", adminHandler.CreateCategory)
admin.PATCH("/categories/:id", adminHandler.UpdateCategory) admin.PATCH("/categories/:id", adminHandler.UpdateCategory)
// Neighborhoods
admin.GET("/neighborhoods", adminHandler.ListNeighborhoods)
admin.POST("/neighborhoods/:id/admins", adminHandler.SetNeighborhoodAdmin)
admin.GET("/neighborhoods/:id/admins", adminHandler.ListNeighborhoodAdmins)
admin.GET("/neighborhoods/:id/board", adminHandler.ListNeighborhoodBoardEntries)
admin.PATCH("/neighborhoods/:id/board/:entryId", adminHandler.UpdateNeighborhoodBoardEntry)
// System // System
admin.GET("/health", adminHandler.GetSystemHealth) admin.GET("/health", adminHandler.GetSystemHealth)
admin.GET("/audit-log", adminHandler.GetAuditLog) admin.GET("/audit-log", adminHandler.GetAuditLog)
@ -477,6 +603,7 @@ func main() {
// AI Moderation Config // AI Moderation Config
admin.GET("/ai/models", adminHandler.ListOpenRouterModels) admin.GET("/ai/models", adminHandler.ListOpenRouterModels)
admin.GET("/ai/models/local", adminHandler.ListLocalModels)
admin.GET("/ai/config", adminHandler.GetAIModerationConfigs) admin.GET("/ai/config", adminHandler.GetAIModerationConfigs)
admin.PUT("/ai/config", adminHandler.SetAIModerationConfig) admin.PUT("/ai/config", adminHandler.SetAIModerationConfig)
admin.POST("/ai/test", adminHandler.TestAIModeration) admin.POST("/ai/test", adminHandler.TestAIModeration)
@ -501,12 +628,26 @@ func main() {
admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost) admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost)
admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles) admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles)
admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles) admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles)
admin.POST("/official-accounts/:id/articles/cleanup", adminHandler.CleanupPendingArticles)
admin.POST("/official-accounts/articles/:article_id/skip", adminHandler.SkipArticle)
admin.POST("/official-accounts/articles/:article_id/post", adminHandler.PostSpecificArticle)
admin.DELETE("/official-accounts/articles/:article_id", adminHandler.DeleteArticle)
// AI Engines Status
admin.GET("/ai-engines", adminHandler.GetAIEngines)
admin.POST("/upload-test-image", adminHandler.UploadTestImage)
// Safe Domains Management // Safe Domains Management
admin.GET("/safe-domains", adminHandler.ListSafeDomains) admin.GET("/safe-domains", adminHandler.ListSafeDomains)
admin.POST("/safe-domains", adminHandler.UpsertSafeDomain) admin.POST("/safe-domains", adminHandler.UpsertSafeDomain)
admin.DELETE("/safe-domains/:id", adminHandler.DeleteSafeDomain) admin.DELETE("/safe-domains/:id", adminHandler.DeleteSafeDomain)
admin.GET("/safe-domains/check", adminHandler.CheckURLSafety) admin.GET("/safe-domains/check", adminHandler.CheckURLSafety)
// Email Templates
admin.GET("/email-templates", adminHandler.ListEmailTemplates)
admin.GET("/email-templates/:id", adminHandler.GetEmailTemplate)
admin.PATCH("/email-templates/:id", adminHandler.UpdateEmailTemplate)
admin.POST("/email-templates/test", adminHandler.SendTestEmail)
} }
// Public claim request endpoint (no auth) // Public claim request endpoint (no auth)

View file

@ -6,8 +6,8 @@ import (
"os" "os"
"time" "time"
"github.com/patbritton/sojorn-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"github.com/patbritton/sojorn-backend/internal/database" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )

View file

@ -14,11 +14,10 @@ func main() {
// Try to load .env, but don't fail if missing (env vars might be set manually) // Try to load .env, but don't fail if missing (env vars might be set manually)
_ = godotenv.Load("../../.env") _ = godotenv.Load("../../.env")
// Get DB URL from env or use the hardcoded one we saw in logs // Get DB URL from env
connStr := os.Getenv("DATABASE_URL") connStr := os.Getenv("DATABASE_URL")
if connStr == "" { if connStr == "" {
// Fallback to the known connection string from your .env log.Fatal("DATABASE_URL is required")
connStr = "postgres://postgres:A24Zr7AEoch4eO0N@localhost:5432/postgres?sslmode=disable"
} }
fmt.Println("Connecting to DB...") fmt.Println("Connecting to DB...")

View file

@ -9,8 +9,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/patbritton/sojorn-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"github.com/patbritton/sojorn-backend/internal/database" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )

View file

@ -7,8 +7,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/patbritton/sojorn-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"github.com/patbritton/sojorn-backend/internal/database" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )

View file

@ -5,8 +5,8 @@ import (
"os" "os"
"time" "time"
"github.com/patbritton/sojorn-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"github.com/patbritton/sojorn-backend/internal/database" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/database"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )

View file

@ -10,8 +10,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/patbritton/sojorn-backend/internal/models" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
"github.com/patbritton/sojorn-backend/internal/repository" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
) )
func main() { func main() {

View file

@ -1,26 +0,0 @@
version: '3'
services:
directus:
image: directus/directus:latest
ports:
- 8055:8055
volumes:
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
network_mode: "host"
environment:
KEY: "sj_auth_key_replace_me_securely"
SECRET: "sj_auth_secret_replace_me_securely"
# Connect directly to the main 'postgres' database (Sojorn's DB)
DB_CLIENT: "pg"
DB_HOST: "127.0.0.1"
DB_PORT: "5432"
DB_DATABASE: "postgres"
DB_USER: "postgres"
DB_PASSWORD: "A24Zr7AEoch4eO0N"
ADMIN_EMAIL: "admin@sojorn.com"
ADMIN_PASSWORD: "Password123!"
PUBLIC_URL: "https://sojorn.net/cms"

View file

@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "britton86-6c1a4",
"private_key_id": "e8110c05f9145d3e0f3d426c1243110c100438d9",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCoz+1n/0FxNKSM\nhgS0W6DpltN/QOwEUUBYu/6/aQtnC0oUrdB8ROIgxbLh4e+2RswKRnut7179adqH\nPe9uChldT3mcYStDvcrsmQp1XzrOE9hUxVHM9cwf4Pt6EfPLzGUHJCph1qW9vOVn\n+kDSwyQE0t/zBf2LMDTmQVZTuPoj79BWW+WRwH472useQGh2vcUm2kGqOR+e06c6\nW8yyCNkbVNdrgIj+rb/E2TdvUBGyuJX7A6y/vD7rzcXWxaji/L9w4Iz7+ivUVqyR\nB7sMXCNYam3cejehxzXghIcyhOCizzEkbjk0Hplm7BeG3368C04re35AtLwP93AA\nKocAXX+/AgMBAAECggEABJ9Q5liYb6Ob39cpmNCresns956M999LrkJTiuUy8Trf\nZ+qMe2KdeH1BVSsNF8YffyaH627TAIhd+fcJr88p+6KZ91y3xQ6U2F6maSlW4F6t\nvUB76WtPkA3mhOSp8soheA0W9f/dIPFjrLGvBbZfLZyikjJ3S1DiGe3vEbLOcp5q\nhINV/voDERZCSc4rY4zR2tj0SRZGe3fFrlBjlZwTWapx84PSAmujYadAXXZHe1M9\nQA92WJp+2ZBVv6Q7fF3DUZVt5ONRhVrF4lboiDOyBh6faInzy7a8Rg/DZug773jK\nHTj+ON5kmnZAX3VzV5qhzMuYa9PhfMj4HWdRP3wE8QKBgQDby7Rr3UcLbXNeAUas\nEgB6klGU0lCb0IZJ4I3x7+tK8C/JYJFolP70T3piXX7uWoKcFFlqGaxziUI7QAql\n5OMm5OSuUbP3mb/MwDeywqA2G+b6wKw7aF5yY5OavT9i+PEZnH/kmafI9fXqKMAg\nqu7qBIbIHF5T9xAHlhN5+6XGUQKBgQDEnlymYjsZxgM1gw2uKu7g6wMOju4RkscH\n4uN7Ufadiop31FQgBZZJK4ahnGODaPEPwZ1vOE8R68F+Lk63TGJA+gJdKFqLuZWg\nrGShDyP2rZ7WBNtGqwgvlJ35IV56fuU2aivlNQSMjoM35PWrGC0YjcGJpMIRjS0u\nla6IO2ORDwKBgAbjfYYb60waZBFALPzbm5Q73b6yUMBxaqQKG6jHgjJZEMZY9nW2\npb72drl6gK3rvEg0AxFmOJduZ9r/iNXmNJBVgC1Odjt+YBqEs7owi2Dmwvh87Wj3\nPm6LXGbvI3twne3Vj9SUVEPiIZDzMgJUGSTQe4DuEq7DAYebVoTuNCXhAoGAJS+6\nYDGV8fL0amuF69ns4hcwtdEsj6BOClzMH2fKF9O7Cpza6E+GNAKKbQhx/cmcRhmd\nWAqzUbVgHChP9PT6ZEWkqs/WCDUqaoAQbDG74IzHzLyQaFYyryURl6vK/aoAWgFM\nmgYj/R17Ddg86oYhCVLONuU1WzJzSCtBHjz1QNsCgYEA1gMf9o8QFhvC6nBi1KcG\nIa0GZLECV5gKvA+FjfM1N0T/ZLtxpJPmGS/goSKyddYZXLnskxP4dTHgE9BZs/ZE\n/UCRdGw7fVwuI/hL9CODgGMRpbCFYLcXtGZJeSe0+7fBi14MMrBxtmAmpT0DJuZb\n6G/pBSvSYG6USbv+qOQ/C6g=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-5007c@britton86-6c1a4.iam.gserviceaccount.com",
"client_id": "104197666755456354541",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-5007c%40britton86-6c1a4.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View file

@ -1,4 +1,4 @@
module github.com/patbritton/sojorn-backend module gitlab.com/patrickbritton3/sojorn/go-backend
go 1.25.4 go 1.25.4
@ -19,7 +19,9 @@ require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/pashagolub/pgxmock/v4 v4.9.0 github.com/pashagolub/pgxmock/v4 v4.9.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.34.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
google.golang.org/api v0.262.0 google.golang.org/api v0.262.0
) )
@ -39,7 +41,6 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/NaySoftware/go-fcm v0.0.0-20190516140123-808e978ddcd2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
@ -60,6 +61,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
@ -88,13 +90,13 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oschwald/geoip2-golang v1.13.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@ -110,7 +112,6 @@ require (
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
@ -121,4 +122,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect
google.golang.org/grpc v1.78.0 // indirect google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View file

@ -34,8 +34,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/NaySoftware/go-fcm v0.0.0-20190516140123-808e978ddcd2 h1:0hjpEzUWez7uca/CUBhfidfotTCCI5fsj6Nb+TW5DLg=
github.com/NaySoftware/go-fcm v0.0.0-20190516140123-808e978ddcd2/go.mod h1:3qVrdgWvoMZMoRG+/nusrCNrcP4RYU4MWGv467XjqLI=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
@ -167,6 +165,10 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/koddr/go-email-sender v0.0.5 h1:cPZxblN5IQdaS6sL8VHZb63C4rbmTCbmBEU15NvZGEo= github.com/koddr/go-email-sender v0.0.5 h1:cPZxblN5IQdaS6sL8VHZb63C4rbmTCbmBEU15NvZGEo=
github.com/koddr/go-email-sender v0.0.5/go.mod h1:2m45qi5oUa+3gMdO0BFTKBx6RHF5y/gNDu6f1dV7mkc= github.com/koddr/go-email-sender v0.0.5/go.mod h1:2m45qi5oUa+3gMdO0BFTKBx6RHF5y/gNDu6f1dV7mkc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@ -182,10 +184,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I= github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I=
github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@ -200,6 +198,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@ -208,6 +208,8 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -308,6 +310,8 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -39,6 +39,11 @@ type Config struct {
APIBaseURL string APIBaseURL string
AppBaseURL string AppBaseURL string
OpenRouterAPIKey string OpenRouterAPIKey string
AIGatewayURL string
AIGatewayToken string
AzureOpenAIAPIKey string
AzureOpenAIEndpoint string
AzureOpenAIAPIVersion string
} }
func LoadConfig() *Config { func LoadConfig() *Config {
@ -82,8 +87,13 @@ func LoadConfig() *Config {
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"), R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""), TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"), APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
AppBaseURL: getEnv("APP_BASE_URL", "https://sojorn.net"), AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""), OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
AIGatewayURL: getEnv("AI_GATEWAY_URL", ""),
AIGatewayToken: getEnv("AI_GATEWAY_TOKEN", ""),
AzureOpenAIAPIKey: getEnv("AZURE_OPENAI_API_KEY", ""),
AzureOpenAIEndpoint: getEnv("AZURE_OPENAI_ENDPOINT", ""),
AzureOpenAIAPIVersion: getEnv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"),
} }
} }

View file

@ -8,6 +8,7 @@ import (
type ModerationConfig struct { type ModerationConfig struct {
OpenAIKey string OpenAIKey string
GoogleKey string GoogleKey string
GoogleCredsFile string
Enabled bool Enabled bool
} }
@ -16,6 +17,7 @@ func NewModerationConfig() *ModerationConfig {
return &ModerationConfig{ return &ModerationConfig{
OpenAIKey: os.Getenv("OPENAI_API_KEY"), OpenAIKey: os.Getenv("OPENAI_API_KEY"),
GoogleKey: os.Getenv("GOOGLE_VISION_API_KEY"), GoogleKey: os.Getenv("GOOGLE_VISION_API_KEY"),
GoogleCredsFile: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
Enabled: os.Getenv("MODERATION_ENABLED") != "false", Enabled: os.Getenv("MODERATION_ENABLED") != "false",
} }
} }
@ -32,5 +34,5 @@ func (c *ModerationConfig) HasOpenAI() bool {
// HasGoogleVision returns true if Google Vision API is configured // HasGoogleVision returns true if Google Vision API is configured
func (c *ModerationConfig) HasGoogleVision() bool { func (c *ModerationConfig) HasGoogleVision() bool {
return c.GoogleKey != "" return c.GoogleKey != "" || c.GoogleCredsFile != ""
} }

View file

@ -1,26 +0,0 @@
-- 000001_initial_schema.down.sql
DROP TABLE IF EXISTS reports;
DROP TABLE IF EXISTS blocks;
DROP TABLE IF EXISTS follows;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS beacon_votes;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS post_saves;
DROP TABLE IF EXISTS post_likes;
DROP TABLE IF EXISTS post_metrics;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS trust_state;
DROP TABLE IF EXISTS profiles;
DROP TABLE IF EXISTS users;
DROP TYPE IF EXISTS post_status;
DROP TYPE IF EXISTS tone_label;
DROP TYPE IF EXISTS notification_type;
DROP TYPE IF EXISTS trust_tier;
DROP TYPE IF EXISTS beacon_type;
DROP SCHEMA IF EXISTS auth;
-- Note: We don't drop extensions as they might be used by other databases

View file

@ -1,254 +0,0 @@
-- 000001_initial_schema.up.sql
-- EXTENSIONS
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE EXTENSION IF NOT EXISTS "postgis";
-- TYPES
DO $$ BEGIN
CREATE TYPE beacon_type AS ENUM ('police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community');
CREATE TYPE trust_tier AS ENUM ('new', 'trusted', 'established');
CREATE TYPE notification_type AS ENUM ('appreciate', 'chain', 'follow', 'comment', 'mention', 'follow_request', 'new_follower', 'request_accepted');
CREATE TYPE tone_label AS ENUM ('positive', 'neutral', 'mixed', 'negative', 'hostile');
CREATE TYPE post_status AS ENUM ('active', 'flagged', 'removed');
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- AUTH SCHEMA (Full Parity)
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
instance_id UUID,
id UUID PRIMARY KEY,
aud TEXT,
role TEXT,
email TEXT,
encrypted_password TEXT,
email_confirmed_at TIMESTAMPTZ,
invited_at TIMESTAMPTZ,
confirmation_token TEXT,
confirmation_sent_at TIMESTAMPTZ,
recovery_token TEXT,
recovery_sent_at TIMESTAMPTZ,
email_change_token_new TEXT,
email_change TEXT,
email_change_sent_at TIMESTAMPTZ,
last_sign_in_at TIMESTAMPTZ,
raw_app_meta_data JSONB,
raw_user_meta_data JSONB,
is_super_admin BOOLEAN,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
phone TEXT DEFAULT NULL,
phone_confirmed_at TIMESTAMPTZ DEFAULT NULL,
phone_change TEXT DEFAULT NULL,
phone_change_sent_at TIMESTAMPTZ DEFAULT NULL,
phone_change_token TEXT DEFAULT NULL,
email_change_token_current TEXT DEFAULT NULL,
email_change_confirm_status SMALLINT DEFAULT 0,
banned_until TIMESTAMPTZ DEFAULT NULL,
reauthentication_token TEXT DEFAULT NULL,
reauthentication_sent_at TIMESTAMPTZ DEFAULT NULL,
is_sso_user BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMPTZ DEFAULT NULL,
is_anonymous BOOLEAN DEFAULT FALSE
);
-- SUPABASE FUNCTIONS (Compatibility)
CREATE SCHEMA IF NOT EXISTS supabase_functions;
CREATE TABLE IF NOT EXISTS supabase_functions.hooks (
id BIGSERIAL PRIMARY KEY,
hook_table_id INTEGER,
hook_name TEXT,
created_at TIMESTAMPTZ,
request_id TEXT
);
-- CORE TABLES
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT,
supabase_id UUID UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
is_anonymous BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
handle TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_official BOOLEAN DEFAULT FALSE,
location TEXT,
website TEXT,
interests TEXT[],
avatar_url TEXT,
cover_url TEXT,
account_status TEXT DEFAULT 'active',
deactivated_at TIMESTAMPTZ,
deletion_requested_at TIMESTAMPTZ,
last_handle_change_at TIMESTAMPTZ,
beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE,
strikes INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL DEFAULT 'user',
origin_country TEXT,
is_private BOOLEAN NOT NULL DEFAULT FALSE,
identity_key TEXT,
registration_id INTEGER,
encrypted_private_key TEXT
);
CREATE TABLE IF NOT EXISTS categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
is_sensitive BOOLEAN NOT NULL DEFAULT FALSE,
default_off BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS trust_state (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
harmony_score INTEGER NOT NULL DEFAULT 50,
tier TEXT NOT NULL DEFAULT 'new',
counters JSONB NOT NULL DEFAULT '{}'::jsonb,
last_post_at TIMESTAMPTZ,
posts_today INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tier_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tone_label TEXT,
cis_score NUMERIC,
status TEXT NOT NULL DEFAULT 'active',
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
allow_chain BOOLEAN NOT NULL DEFAULT TRUE,
chain_parent_id UUID REFERENCES posts(id) ON DELETE SET NULL,
image_url TEXT,
body_format TEXT,
background_id TEXT,
is_edited BOOLEAN NOT NULL DEFAULT FALSE,
tags TEXT[],
location geography(POINT),
is_beacon BOOLEAN DEFAULT FALSE,
beacon_type TEXT,
confidence_score DOUBLE PRECISION,
is_active_beacon BOOLEAN DEFAULT TRUE,
fts TSVECTOR,
expires_at TIMESTAMPTZ,
moderation_status TEXT NOT NULL DEFAULT 'approved',
visibility TEXT NOT NULL DEFAULT 'authenticated',
pinned_at TIMESTAMPTZ,
type TEXT NOT NULL DEFAULT 'post',
video_url TEXT,
thumbnail_url TEXT,
duration_ms INTEGER
);
CREATE TABLE IF NOT EXISTS post_metrics (
post_id UUID PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
like_count INTEGER NOT NULL DEFAULT 0,
save_count INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS post_likes (
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
CREATE TABLE IF NOT EXISTS post_saves (
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
CREATE TABLE IF NOT EXISTS comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tone_label TEXT,
status TEXT NOT NULL DEFAULT 'active',
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS beacon_votes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
beacon_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
vote_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS follows (
follower_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'accepted',
PRIMARY KEY (follower_id, following_id)
);
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
type TEXT NOT NULL,
actor_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
comment_id UUID REFERENCES comments(id) ON DELETE SET NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
archived_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_fcm_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token TEXT NOT NULL,
device_type TEXT,
last_updated TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, token)
);
CREATE TABLE IF NOT EXISTS profile_privacy_settings (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
show_location BOOLEAN DEFAULT TRUE,
show_interests BOOLEAN DEFAULT TRUE,
profile_visibility TEXT DEFAULT 'public',
posts_visibility TEXT DEFAULT 'public',
saved_visibility TEXT DEFAULT 'public',
follow_request_policy TEXT DEFAULT 'anyone',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sponsored_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
advertiser_name TEXT, -- Added
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- INDEXES
CREATE INDEX IF NOT EXISTS idx_profiles_handle ON profiles(handle);
CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_id);
CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id);
CREATE INDEX IF NOT EXISTS idx_posts_location ON posts USING GIST (location);

View file

@ -1,10 +0,0 @@
-- 000002_e2ee_chat.down.sql
DROP FUNCTION IF EXISTS consume_one_time_prekey;
DROP FUNCTION IF EXISTS get_or_create_conversation;
DROP TABLE IF EXISTS e2ee_session_events;
DROP TABLE IF EXISTS e2ee_session_commands;
DROP TABLE IF EXISTS encrypted_messages;
DROP TABLE IF EXISTS encrypted_conversations;
DROP TABLE IF EXISTS signal_keys;

View file

@ -1,32 +0,0 @@
-- Create table for Signed Prekeys
CREATE TABLE IF NOT EXISTS public.signed_prekeys (
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, key_id)
);
-- Create table for One-Time Prekeys
CREATE TABLE IF NOT EXISTS public.one_time_prekeys (
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, key_id)
);
CREATE INDEX IF NOT EXISTS idx_one_time_prekeys_user_created ON public.one_time_prekeys(user_id, created_at ASC);
-- Create table for Secure Messages
CREATE TABLE IF NOT EXISTS public.secure_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL,
sender_id UUID NOT NULL REFERENCES public.users(id),
receiver_id UUID NOT NULL REFERENCES public.users(id),
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
key_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secure_messages_conv_created ON public.secure_messages(conversation_id, created_at DESC);

View file

@ -1,136 +0,0 @@
-- E2EE Backup & Recovery System Migration
-- Creates tables for device sync, cloud backups, and social recovery
-- Sync codes table for device-to-device pairing
CREATE TABLE IF NOT EXISTS sync_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
code VARCHAR(6) NOT NULL,
device_fingerprint TEXT,
device_name TEXT,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
attempts INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for sync codes
CREATE INDEX IF NOT EXISTS idx_sync_codes_code ON sync_codes(code) WHERE used_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sync_codes_expires ON sync_codes(expires_at) WHERE used_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sync_codes_user ON sync_codes(user_id, created_at DESC);
-- Cloud backups table
CREATE TABLE IF NOT EXISTS cloud_backups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
encrypted_blob BYTEA NOT NULL,
salt BYTEA NOT NULL,
nonce BYTEA NOT NULL,
mac BYTEA NOT NULL,
version INT DEFAULT 1,
device_name TEXT,
size_bytes BIGINT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for cloud backups
CREATE INDEX IF NOT EXISTS idx_backups_user ON cloud_backups(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_backups_version ON cloud_backups(user_id, version);
-- Recovery guardians table for social recovery
CREATE TABLE IF NOT EXISTS recovery_guardians (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
guardian_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
shard_encrypted BYTEA NOT NULL,
shard_index INT NOT NULL,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'revoked')),
invited_at TIMESTAMP DEFAULT NOW(),
responded_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for recovery guardians
CREATE INDEX IF NOT EXISTS idx_guardians_user ON recovery_guardians(user_id);
CREATE INDEX IF NOT EXISTS idx_guardians_guardian ON recovery_guardians(guardian_user_id);
CREATE INDEX IF NOT EXISTS idx_guardians_status ON recovery_guardians(status);
-- Recovery sessions table
CREATE TABLE IF NOT EXISTS recovery_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
method VARCHAR(20) NOT NULL CHECK (method IN ('social', 'email', 'questions')),
shards_received INT DEFAULT 0,
shards_needed INT NOT NULL,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'expired', 'failed')),
expires_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for recovery sessions
CREATE INDEX IF NOT EXISTS idx_sessions_user ON recovery_sessions(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON recovery_sessions(status);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON recovery_sessions(expires_at);
-- Recovery shard submissions table
CREATE TABLE IF NOT EXISTS recovery_shard_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES public.recovery_sessions(id) ON DELETE CASCADE,
guardian_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
shard_encrypted BYTEA NOT NULL,
submitted_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for shard submissions
CREATE INDEX IF NOT EXISTS idx_shards_session ON recovery_shard_submissions(session_id);
CREATE INDEX IF NOT EXISTS idx_shards_guardian ON recovery_shard_submissions(guardian_user_id);
-- User backup preferences table
CREATE TABLE IF NOT EXISTS backup_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
cloud_backup_enabled BOOLEAN DEFAULT false,
auto_backup_enabled BOOLEAN DEFAULT false,
backup_frequency_hours INT DEFAULT 24,
last_backup_at TIMESTAMP,
backup_password_hash TEXT, -- Argon2id hash of backup password
backup_salt BYTEA, -- Salt for backup password derivation
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index for backup preferences
CREATE INDEX IF NOT EXISTS idx_backup_preferences_user ON backup_preferences(user_id);
-- Device registry table for tracking user devices
CREATE TABLE IF NOT EXISTS user_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
device_fingerprint TEXT NOT NULL,
device_name TEXT,
device_type VARCHAR(20) CHECK (device_type IN ('android', 'ios', 'web', 'desktop')),
last_seen_at TIMESTAMP,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for user devices
CREATE INDEX IF NOT EXISTS idx_devices_user ON user_devices(user_id);
CREATE INDEX IF NOT EXISTS idx_devices_fingerprint ON user_devices(device_fingerprint);
CREATE INDEX IF NOT EXISTS idx_devices_active ON user_devices(user_id, is_active);
-- Insert default backup preferences for existing users
INSERT INTO backup_preferences (user_id)
SELECT id FROM public.users
WHERE id NOT IN (SELECT user_id FROM backup_preferences);
-- Comments for documentation
COMMENT ON TABLE sync_codes IS 'Stores temporary 6-digit codes for device-to-device sync pairing';
COMMENT ON TABLE cloud_backups IS 'Stores encrypted user backup blobs in Firebase Storage';
COMMENT ON TABLE recovery_guardians IS 'Stores encrypted key shards for social recovery';
COMMENT ON TABLE recovery_sessions IS 'Tracks recovery attempts and progress';
COMMENT ON TABLE recovery_shard_submissions IS 'Stores individual shard submissions during recovery';
COMMENT ON TABLE backup_preferences IS 'User preferences for backup settings';
COMMENT ON TABLE user_devices IS 'Registry of user devices for sync management';

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS refresh_tokens;

View file

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);

View file

@ -1,10 +0,0 @@
-- 000010_notification_preferences.down.sql
DROP TRIGGER IF EXISTS notification_count_trigger ON notifications;
DROP FUNCTION IF EXISTS update_unread_notification_count();
ALTER TABLE notifications DROP COLUMN IF EXISTS group_key;
ALTER TABLE notifications DROP COLUMN IF EXISTS priority;
ALTER TABLE profiles DROP COLUMN IF EXISTS unread_notification_count;
DROP TABLE IF EXISTS notification_preferences;

View file

@ -1,72 +0,0 @@
-- 000010_notification_preferences.up.sql
-- User notification preferences for granular control
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
-- Push notification toggles
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
push_likes BOOLEAN NOT NULL DEFAULT TRUE,
push_comments BOOLEAN NOT NULL DEFAULT TRUE,
push_replies BOOLEAN NOT NULL DEFAULT TRUE,
push_mentions BOOLEAN NOT NULL DEFAULT TRUE,
push_follows BOOLEAN NOT NULL DEFAULT TRUE,
push_follow_requests BOOLEAN NOT NULL DEFAULT TRUE,
push_messages BOOLEAN NOT NULL DEFAULT TRUE,
push_saves BOOLEAN NOT NULL DEFAULT TRUE,
push_beacons BOOLEAN NOT NULL DEFAULT TRUE,
-- Email notification toggles (for future use)
email_enabled BOOLEAN NOT NULL DEFAULT FALSE,
email_digest_frequency TEXT NOT NULL DEFAULT 'never', -- 'never', 'daily', 'weekly'
-- Quiet hours (UTC)
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT FALSE,
quiet_hours_start TIME, -- e.g., '22:00:00'
quiet_hours_end TIME, -- e.g., '08:00:00'
-- Badge settings
show_badge_count BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add unread count cache to profiles for badge display
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS unread_notification_count INTEGER NOT NULL DEFAULT 0;
-- Add group_key for notification batching/grouping
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS group_key TEXT;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'; -- 'low', 'normal', 'high', 'urgent'
-- Create indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read) WHERE is_read = FALSE;
CREATE INDEX IF NOT EXISTS idx_notifications_group_key ON notifications(group_key) WHERE group_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user ON user_fcm_tokens(user_id);
-- Function to update unread count on notification insert
CREATE OR REPLACE FUNCTION update_unread_notification_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE profiles SET unread_notification_count = unread_notification_count + 1 WHERE id = NEW.user_id;
ELSIF TG_OP = 'UPDATE' AND OLD.is_read = FALSE AND NEW.is_read = TRUE THEN
UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = NEW.user_id;
ELSIF TG_OP = 'DELETE' AND OLD.is_read = FALSE THEN
UPDATE profiles SET unread_notification_count = GREATEST(0, unread_notification_count - 1) WHERE id = OLD.user_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for automatic badge count updates
DROP TRIGGER IF EXISTS notification_count_trigger ON notifications;
CREATE TRIGGER notification_count_trigger
AFTER INSERT OR UPDATE OR DELETE ON notifications
FOR EACH ROW
EXECUTE FUNCTION update_unread_notification_count();
-- Initialize notification preferences for existing users
INSERT INTO notification_preferences (user_id)
SELECT id FROM profiles
ON CONFLICT (user_id) DO NOTHING;

View file

@ -1,12 +0,0 @@
-- 000011_tagging_system.down.sql
DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags;
DROP FUNCTION IF EXISTS update_hashtag_count();
DROP FUNCTION IF EXISTS calculate_trending_scores();
DROP TABLE IF EXISTS suggested_users;
DROP TABLE IF EXISTS trending_hashtags;
DROP TABLE IF EXISTS post_mentions;
DROP TABLE IF EXISTS hashtag_follows;
DROP TABLE IF EXISTS post_hashtags;
DROP TABLE IF EXISTS hashtags;

View file

@ -1,133 +0,0 @@
-- 000011_tagging_system.up.sql
-- Complete tagging system for hashtags and mentions
-- Hashtags master table
CREATE TABLE IF NOT EXISTS hashtags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL, -- lowercase, without #
display_name TEXT NOT NULL, -- original casing
use_count INTEGER NOT NULL DEFAULT 0,
trending_score FLOAT NOT NULL DEFAULT 0,
is_trending BOOLEAN NOT NULL DEFAULT FALSE,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
category TEXT, -- optional category grouping
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(name)
);
-- Post-to-hashtag junction table
CREATE TABLE IF NOT EXISTS post_hashtags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (post_id, hashtag_id)
);
-- User-to-hashtag follows (users can follow hashtags)
CREATE TABLE IF NOT EXISTS hashtag_follows (
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, hashtag_id)
);
-- Post mentions (tracks @mentions in posts)
CREATE TABLE IF NOT EXISTS post_mentions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
mentioned_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(post_id, mentioned_user_id)
);
-- Trending hashtags snapshot (for discover page)
CREATE TABLE IF NOT EXISTS trending_hashtags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
hashtag_id UUID NOT NULL REFERENCES hashtags(id) ON DELETE CASCADE,
rank INTEGER NOT NULL,
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
period TEXT NOT NULL DEFAULT 'daily', -- 'hourly', 'daily', 'weekly'
post_count_in_period INTEGER NOT NULL DEFAULT 0,
UNIQUE(hashtag_id, period)
);
-- Suggested users for discover page
CREATE TABLE IF NOT EXISTS suggested_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
reason TEXT NOT NULL, -- 'popular', 'category', 'similar', 'new_creator'
category TEXT,
score FLOAT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, reason)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_hashtags_name ON hashtags(name);
CREATE INDEX IF NOT EXISTS idx_hashtags_trending ON hashtags(is_trending, trending_score DESC);
CREATE INDEX IF NOT EXISTS idx_hashtags_use_count ON hashtags(use_count DESC);
CREATE INDEX IF NOT EXISTS idx_post_hashtags_hashtag ON post_hashtags(hashtag_id);
CREATE INDEX IF NOT EXISTS idx_post_mentions_user ON post_mentions(mentioned_user_id);
CREATE INDEX IF NOT EXISTS idx_trending_hashtags_rank ON trending_hashtags(period, rank);
CREATE INDEX IF NOT EXISTS idx_suggested_users_active ON suggested_users(is_active, score DESC);
-- Function to update hashtag use count
CREATE OR REPLACE FUNCTION update_hashtag_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE hashtags SET use_count = use_count + 1, updated_at = NOW() WHERE id = NEW.hashtag_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE hashtags SET use_count = GREATEST(0, use_count - 1), updated_at = NOW() WHERE id = OLD.hashtag_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS hashtag_count_trigger ON post_hashtags;
CREATE TRIGGER hashtag_count_trigger
AFTER INSERT OR DELETE ON post_hashtags
FOR EACH ROW
EXECUTE FUNCTION update_hashtag_count();
-- Function to calculate trending score (decay-based)
CREATE OR REPLACE FUNCTION calculate_trending_scores()
RETURNS void AS $$
DECLARE
decay_factor FLOAT := 0.95;
hours_window INTEGER := 24;
BEGIN
-- Calculate trending score based on recent usage with time decay
UPDATE hashtags h
SET
trending_score = COALESCE((
SELECT SUM(POWER(decay_factor, EXTRACT(EPOCH FROM (NOW() - ph.created_at)) / 3600))
FROM post_hashtags ph
WHERE ph.hashtag_id = h.id
AND ph.created_at > NOW() - INTERVAL '24 hours'
), 0),
is_trending = (
SELECT COUNT(*) >= 5
FROM post_hashtags ph
WHERE ph.hashtag_id = h.id
AND ph.created_at > NOW() - INTERVAL '24 hours'
),
updated_at = NOW();
-- Update trending_hashtags table
DELETE FROM trending_hashtags WHERE period = 'daily';
INSERT INTO trending_hashtags (hashtag_id, rank, period, post_count_in_period)
SELECT
h.id,
ROW_NUMBER() OVER (ORDER BY h.trending_score DESC),
'daily',
(SELECT COUNT(*) FROM post_hashtags ph WHERE ph.hashtag_id = h.id AND ph.created_at > NOW() - INTERVAL '24 hours')
FROM hashtags h
WHERE h.trending_score > 0
ORDER BY h.trending_score DESC
LIMIT 50;
END;
$$ LANGUAGE plpgsql;

View file

@ -1,13 +0,0 @@
-- Add privacy settings to profiles
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public',
ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE;
-- Create table for blocked users
CREATE TABLE IF NOT EXISTS public.blocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(blocker_id, blocked_id)
);

View file

@ -1,13 +0,0 @@
-- Add missing columns to profile_privacy_settings
ALTER TABLE public.profile_privacy_settings
ADD COLUMN IF NOT EXISTS default_post_visibility TEXT NOT NULL DEFAULT 'public',
ADD COLUMN IF NOT EXISTS is_private_profile BOOLEAN NOT NULL DEFAULT FALSE;
-- Ensure blocks table exists
CREATE TABLE IF NOT EXISTS public.blocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
blocker_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(blocker_id, blocked_id)
);

View file

@ -1,60 +0,0 @@
-- Structural Blocking & Abuse Tracking
-- Create abuse_logs table
CREATE TABLE IF NOT EXISTS public.abuse_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
blocked_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
blocked_handle TEXT NOT NULL,
actor_ip TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create reports table
CREATE TABLE IF NOT EXISTS public.reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
target_user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
comment_id UUID REFERENCES public.comments(id) ON DELETE SET NULL,
violation_type TEXT NOT NULL, -- e.g., 'hate', 'greed', 'delusion'
description TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending, reviewed, resolved
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create pending_moderation table for AI flags
CREATE TABLE IF NOT EXISTS public.pending_moderation (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE,
comment_id UUID REFERENCES public.comments(id) ON DELETE CASCADE,
flag_reason TEXT NOT NULL,
scores JSONB, -- store AI scores for 'hate', 'greed', 'delusion'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocking Function
CREATE OR REPLACE FUNCTION public.has_block_between(user_a UUID, user_b UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.blocks
WHERE (blocker_id = user_a AND blocked_id = user_b)
OR (blocker_id = user_b AND blocked_id = user_a)
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Note: RLS implementation typically requires the database to be aware of the "current_user_id".
-- Since this is an external Go API, we will enforce structural invisibility in the Repositories/Queries
-- for now if RLS isn't fully configured with the Auth provider's claims.
-- However, we'll set up the policies anyway for parity.
-- Enable RLS
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Post Policy: Hide posts from/to blocked users
-- This assuming jwt.claims.sub can be passed to session configs
-- For now, we will focus on SQL Query enforcement in Repo layer which is more reliable for custom Go Backends

View file

@ -1,5 +0,0 @@
-- Add official_account_id to categories table
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS official_account_id UUID REFERENCES public.profiles(id);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_categories_official_account ON public.categories(official_account_id) WHERE official_account_id IS NOT NULL;

View file

@ -1,43 +0,0 @@
-- 20260126_auth_revamp_v2.up.sql
DO $$ BEGIN
CREATE TYPE user_status AS ENUM ('pending', 'active', 'deactivated');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS status user_status DEFAULT 'active',
ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS public.user_mfa_secrets (
user_id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
secret TEXT NOT NULL,
recovery_codes TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.webauthn_credentials (
id BYTEA PRIMARY KEY, -- Credential ID can be long bytes
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
public_key BYTEA NOT NULL, -- COSE encoded public key
attestation_type TEXT,
aaguid UUID,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_webauthn_user_id ON public.webauthn_credentials(user_id);
CREATE TABLE IF NOT EXISTS public.auth_tokens (
token TEXT PRIMARY KEY, -- Hashed token
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- 'verification', 'password_reset', 'magic_link'
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON public.auth_tokens(user_id);

View file

@ -1,11 +0,0 @@
-- Create user_category_settings table
CREATE TABLE IF NOT EXISTS public.user_category_settings (
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES public.categories(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, category_id)
);
-- Index for faster lookups by user
CREATE INDEX IF NOT EXISTS idx_user_category_settings_user_id ON public.user_category_settings(user_id);

View file

@ -1,43 +0,0 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create Users table in public schema
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
encrypted_password TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Attempt to migrate data from Supabase auth.users if it exists
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'auth' AND tablename = 'users') THEN
INSERT INTO public.users (id, email, encrypted_password, created_at, updated_at)
SELECT
id,
email,
encrypted_password,
created_at,
COALESCE(updated_at, created_at)
FROM auth.users
WHERE email NOT IN (SELECT email FROM public.users)
ON CONFLICT (id) DO NOTHING;
END IF;
END $$;
-- Update profiles foreign key ONLY if profiles table exists
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_id_fkey;
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_id_fkey
FOREIGN KEY (id)
REFERENCES public.users(id)
ON DELETE CASCADE;
END IF;
END $$;

View file

@ -1,2 +0,0 @@
-- Down migration not strictly necessary for additive fixes, but good practice.
-- We usually don't drop columns in a fix migration to avoid data loss if rolled back accidentally.

View file

@ -1,72 +0,0 @@
DO $$
BEGIN
-- 1. Analysis/Scoring Columns
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='tone_label') THEN
ALTER TABLE posts ADD COLUMN tone_label TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='cis_score') THEN
ALTER TABLE posts ADD COLUMN cis_score NUMERIC;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='moderation_status') THEN
ALTER TABLE posts ADD COLUMN moderation_status TEXT NOT NULL DEFAULT 'approved';
END IF;
-- 2. Post Configuration/Metadata
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='visibility') THEN
ALTER TABLE posts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'authenticated';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='expires_at') THEN
ALTER TABLE posts ADD COLUMN expires_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='body_format') THEN
ALTER TABLE posts ADD COLUMN body_format TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='background_id') THEN
ALTER TABLE posts ADD COLUMN background_id TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='tags') THEN
ALTER TABLE posts ADD COLUMN tags TEXT[];
END IF;
-- 3. Media
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='thumbnail_url') THEN
ALTER TABLE posts ADD COLUMN thumbnail_url TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='video_url') THEN
ALTER TABLE posts ADD COLUMN video_url TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='duration_ms') THEN
ALTER TABLE posts ADD COLUMN duration_ms INTEGER;
END IF;
-- 4. Beacons (Geo)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='location') THEN
-- Assuming PostGIS extension enabled
ALTER TABLE posts ADD COLUMN location geography(POINT);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='is_beacon') THEN
ALTER TABLE posts ADD COLUMN is_beacon BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='beacon_type') THEN
ALTER TABLE posts ADD COLUMN beacon_type TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='confidence_score') THEN
ALTER TABLE posts ADD COLUMN confidence_score DOUBLE PRECISION;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='posts' AND column_name='is_active_beacon') THEN
ALTER TABLE posts ADD COLUMN is_active_beacon BOOLEAN DEFAULT FALSE;
END IF;
END $$;

View file

@ -1,7 +0,0 @@
-- 20260126_profile_onboarding.up.sql
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS has_completed_onboarding BOOLEAN NOT NULL DEFAULT FALSE;
-- Ensure auth_tokens table exists (covered in previous migration, but safe to re-assert or skip)
-- This specific migration focuses on the profile attribute.

View file

@ -1,6 +0,0 @@
-- 20260126000007_auth_security.down.sql
DROP TABLE IF EXISTS public.webauthn_credentials;
DROP TABLE IF EXISTS public.user_mfa_secrets;
DROP TABLE IF EXISTS public.verification_tokens;
ALTER TABLE public.profiles DROP COLUMN IF EXISTS has_completed_onboarding;

View file

@ -1,36 +0,0 @@
-- 20260126000007_auth_security.up.sql
-- 1. Ensure has_completed_onboarding exists (idempotent)
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS has_completed_onboarding BOOLEAN NOT NULL DEFAULT FALSE;
-- 2. Create verification_tokens table
CREATE TABLE IF NOT EXISTS public.verification_tokens (
token_hash VARCHAR(64) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 3. Create user_mfa_secrets (TOTP)
CREATE TABLE IF NOT EXISTS public.user_mfa_secrets (
user_id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
secret_key TEXT NOT NULL,
recovery_codes TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. Create webauthn_credentials (Passkeys)
CREATE TABLE IF NOT EXISTS public.webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL, -- The base64 URL encoded ID
public_key TEXT NOT NULL, -- The public key
attestation_type TEXT,
aaguid UUID,
sign_count INT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
CONSTRAINT unique_credential_id UNIQUE (credential_id)
);

View file

@ -1,3 +0,0 @@
-- This migration is a one-way data repair operation.
-- Reverting would require knowing the specific R2 account ID which was replaced.
-- No action taken for down migration.

View file

@ -1,21 +0,0 @@
-- Fix R2 URLs in profiles
UPDATE profiles
SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE avatar_url LIKE '%r2.cloudflarestorage.com%';
UPDATE profiles
SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE cover_url LIKE '%r2.cloudflarestorage.com%';
-- Fix R2 URLs in posts
UPDATE posts
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE image_url LIKE '%r2.cloudflarestorage.com%';
UPDATE posts
SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://quips.sojorn.net/', 'g')
WHERE video_url LIKE '%r2.cloudflarestorage.com%';
UPDATE posts
SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com%';

View file

@ -1,4 +0,0 @@
ALTER TABLE public.follows DROP CONSTRAINT IF EXISTS follows_status_check;
ALTER TABLE public.follows DROP COLUMN IF EXISTS status;
ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_official;
ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_private;

View file

@ -1,17 +0,0 @@
-- Add privacy settings to profiles if they don't exist
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS is_private BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_official BOOLEAN NOT NULL DEFAULT false;
-- Add status column to follows for request logic
ALTER TABLE public.follows
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'accepted';
-- Add check constraint to ensure valid status values
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'follows_status_check') THEN
ALTER TABLE public.follows
ADD CONSTRAINT follows_status_check CHECK (status IN ('pending', 'accepted'));
END IF;
END $$;

View file

@ -1,73 +0,0 @@
-- Optimizing E2EE Schema
-- Migration ID: 20260127000002
-- Description: Creates properly structured tables for Signal Protocol keys and messages to replace JSON blobs.
-- 1. One-Time Prekeys
-- Stores individual one-time keys. Critical for atomic "fetch and consume".
CREATE TABLE IF NOT EXISTS public.one_time_prekeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- References profiles(id) usually, but loosen constraint if profiles not strictly sync'd yet
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_otk_user_key UNIQUE (user_id, key_id)
);
-- Index for finding keys quickly by user
CREATE INDEX IF NOT EXISTS idx_otk_user_id ON public.one_time_prekeys(user_id);
-- 2. Signed Prekeys
-- Semi-permanent keys signed by identity key.
CREATE TABLE IF NOT EXISTS public.signed_prekeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
key_id INTEGER NOT NULL,
public_key TEXT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_spk_user_key UNIQUE (user_id, key_id)
);
-- Index to quickly get the latest signed prekey
CREATE INDEX IF NOT EXISTS idx_spk_user_created ON public.signed_prekeys(user_id, created_at DESC);
-- 3. Identities
-- Long-term identity keys.
CREATE TABLE IF NOT EXISTS public.identities (
user_id UUID PRIMARY KEY,
registration_id INTEGER NOT NULL,
public_key TEXT NOT NULL, -- Identity Key Public
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. Secure Messages
-- Optimized storage for E2EE payloads.
-- Note: Replaces or complements 'encrypted_messages' depending on migration strategy.
CREATE TABLE IF NOT EXISTS public.secure_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL,
sender_id UUID NOT NULL,
receiver_id UUID NOT NULL,
ciphertext TEXT NOT NULL, -- Base64 encoded encrypted payload
iv TEXT NOT NULL, -- Base64 encoded IV
key_version TEXT NOT NULL, -- e.g. "v1", "signal-session-id"
message_type INTEGER DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ
);
-- Composite index for fast pagination of chat history
CREATE INDEX IF NOT EXISTS idx_secure_msg_conv_created ON public.secure_messages(conversation_id, created_at DESC);
-- 5. E2EE Sessions (Optional/Future Proofing)
-- Stores session state if server-side session management is desired
CREATE TABLE IF NOT EXISTS public.e2ee_sessions (
user_id UUID NOT NULL,
device_id UUID NOT NULL,
session_state TEXT NOT NULL, -- Serialized session state
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, device_id)
);

View file

@ -1,2 +0,0 @@
ALTER TABLE public.secure_messages
DROP COLUMN IF EXISTS message_header;

View file

@ -1,2 +0,0 @@
ALTER TABLE public.secure_messages
ADD COLUMN IF NOT EXISTS message_header TEXT;

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS post_reactions;

View file

@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS post_reactions (
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, post_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_post_reactions_post_id ON post_reactions(post_id);

View file

@ -1,13 +0,0 @@
-- Enable pg_trgm extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create GIN indices for profiles
CREATE INDEX IF NOT EXISTS idx_profiles_handle_trgm ON profiles USING gin (handle gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_profiles_display_name_trgm ON profiles USING gin (display_name gin_trgm_ops);
-- Create GIN index for post body
CREATE INDEX IF NOT EXISTS idx_posts_body_trgm ON posts USING gin (body gin_trgm_ops);
-- Create GIN index for post tags
-- Assuming tags is a text[] column
CREATE INDEX IF NOT EXISTS idx_posts_tags_gin ON posts USING gin (tags);

View file

@ -1,3 +0,0 @@
-- Rollback circle privacy feature
DROP FUNCTION IF EXISTS public.is_in_circle(UUID, UUID);
DROP TABLE IF EXISTS public.circle_members;

View file

@ -1,26 +0,0 @@
-- Circle (Close Friends) Privacy Feature
-- Allows users to share posts only with a specific inner circle
CREATE TABLE IF NOT EXISTS public.circle_members (
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, member_id),
-- Prevent self-addition
CHECK (user_id != member_id)
);
-- Index for fast circle membership checks
CREATE INDEX IF NOT EXISTS idx_circle_members_user_id ON public.circle_members(user_id);
CREATE INDEX IF NOT EXISTS idx_circle_members_member_id ON public.circle_members(member_id);
-- Helper function to check if a user is in another user's circle
CREATE OR REPLACE FUNCTION public.is_in_circle(circle_owner_id UUID, potential_member_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.circle_members
WHERE user_id = circle_owner_id AND member_id = potential_member_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;

View file

@ -1,23 +0,0 @@
-- Rollback migration: update sojorn.net back to gosojorn.com
-- Update profiles
UPDATE profiles
SET avatar_url = REPLACE(avatar_url, 'sojorn.net', 'gosojorn.com')
WHERE avatar_url LIKE '%sojorn.net%' AND avatar_url LIKE '%img.%';
UPDATE profiles
SET cover_url = REPLACE(cover_url, 'sojorn.net', 'gosojorn.com')
WHERE cover_url LIKE '%sojorn.net%' AND cover_url LIKE '%img.%';
-- Update posts
UPDATE posts
SET image_url = REPLACE(image_url, 'sojorn.net', 'gosojorn.com')
WHERE image_url LIKE '%sojorn.net%' AND image_url LIKE '%img.%';
UPDATE posts
SET video_url = REPLACE(video_url, 'sojorn.net', 'gosojorn.com')
WHERE video_url LIKE '%sojorn.net%' AND video_url LIKE '%quips.%';
UPDATE posts
SET thumbnail_url = REPLACE(thumbnail_url, 'sojorn.net', 'gosojorn.com')
WHERE thumbnail_url LIKE '%sojorn.net%' AND thumbnail_url LIKE '%img.%';

View file

@ -1,23 +0,0 @@
-- Migration to update all legacy gosojorn.com URLs to sojorn.net
-- Update profiles
UPDATE profiles
SET avatar_url = REPLACE(avatar_url, 'gosojorn.com', 'sojorn.net')
WHERE avatar_url LIKE '%gosojorn.com%';
UPDATE profiles
SET cover_url = REPLACE(cover_url, 'gosojorn.com', 'sojorn.net')
WHERE cover_url LIKE '%gosojorn.com%';
-- Update posts
UPDATE posts
SET image_url = REPLACE(image_url, 'gosojorn.com', 'sojorn.net')
WHERE image_url LIKE '%gosojorn.com%';
UPDATE posts
SET video_url = REPLACE(video_url, 'gosojorn.com', 'sojorn.net')
WHERE video_url LIKE '%gosojorn.com%';
UPDATE posts
SET thumbnail_url = REPLACE(thumbnail_url, 'gosojorn.com', 'sojorn.net')
WHERE thumbnail_url LIKE '%gosojorn.com%';

View file

@ -1,29 +0,0 @@
-- Remove triggers
DROP TRIGGER IF EXISTS moderation_flags_updated_at ON moderation_flags;
DROP TRIGGER IF EXISTS user_status_change_log ON users;
-- Remove trigger functions
DROP FUNCTION IF EXISTS update_moderation_flags_updated_at();
DROP FUNCTION IF EXISTS log_user_status_change();
-- Remove indexes
DROP INDEX IF EXISTS idx_moderation_flags_post_id;
DROP INDEX IF EXISTS idx_moderation_flags_comment_id;
DROP INDEX IF EXISTS idx_moderation_flags_status;
DROP INDEX IF EXISTS idx_moderation_flags_created_at;
DROP INDEX IF EXISTS idx_moderation_flags_scores_gin;
DROP INDEX IF EXISTS idx_users_status;
DROP INDEX IF EXISTS idx_user_status_history_user_id;
DROP INDEX IF EXISTS idx_user_status_history_created_at;
-- Remove tables
DROP TABLE IF EXISTS user_status_history;
DROP TABLE IF EXISTS moderation_flags;
-- Remove status column from users table
ALTER TABLE users DROP COLUMN IF EXISTS status;
-- Remove comments
COMMENT ON TABLE moderation_flags IS NULL;
COMMENT ON TABLE user_status_history IS NULL;
COMMENT ON COLUMN users.status IS NULL;

View file

@ -1,105 +0,0 @@
-- Create moderation_flags table for AI-powered content moderation
CREATE TABLE IF NOT EXISTS moderation_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
flag_reason TEXT NOT NULL,
scores JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'escalated')),
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure at least one of post_id or comment_id is set
CONSTRAINT moderation_flags_content_check CHECK (
(post_id IS NOT NULL) OR (comment_id IS NOT NULL)
)
);
-- Add indexes for performance
CREATE INDEX IF NOT EXISTS idx_moderation_flags_post_id ON moderation_flags(post_id);
CREATE INDEX IF NOT EXISTS idx_moderation_flags_comment_id ON moderation_flags(comment_id);
CREATE INDEX IF NOT EXISTS idx_moderation_flags_status ON moderation_flags(status);
CREATE INDEX IF NOT EXISTS idx_moderation_flags_created_at ON moderation_flags(created_at);
-- Add GIN index for JSONB scores to enable efficient querying
CREATE INDEX IF NOT EXISTS idx_moderation_flags_scores_gin ON moderation_flags USING GIN(scores);
-- Add status column to users table for user moderation
ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'banned'));
-- Add index for user status queries
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
-- Create user_status_history table to track status changes
CREATE TABLE IF NOT EXISTS user_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
old_status TEXT,
new_status TEXT NOT NULL,
reason TEXT,
changed_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add index for user status history queries
CREATE INDEX IF NOT EXISTS idx_user_status_history_user_id ON user_status_history(user_id);
CREATE INDEX IF NOT EXISTS idx_user_status_history_created_at ON user_status_history(created_at);
-- Create trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_moderation_flags_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER moderation_flags_updated_at
BEFORE UPDATE ON moderation_flags
FOR EACH ROW
EXECUTE FUNCTION update_moderation_flags_updated_at();
-- Create trigger to track user status changes
CREATE OR REPLACE FUNCTION log_user_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO user_status_history (user_id, old_status, new_status, changed_by)
VALUES (NEW.id, OLD.status, NEW.status, NEW.id);
END IF;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER user_status_change_log
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION log_user_status_change();
-- Grant permissions to Directus
GRANT SELECT, INSERT, UPDATE, DELETE ON moderation_flags TO directus;
GRANT SELECT, INSERT, UPDATE, DELETE ON user_status_history TO directus;
GRANT SELECT, UPDATE ON users TO directus;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO directus;
-- Add comments for Directus UI
COMMENT ON TABLE moderation_flags IS 'AI-powered content moderation flags for posts and comments';
COMMENT ON COLUMN moderation_flags.id IS 'Unique identifier for the moderation flag';
COMMENT ON COLUMN moderation_flags.post_id IS 'Reference to the post being moderated';
COMMENT ON COLUMN moderation_flags.comment_id IS 'Reference to the comment being moderated';
COMMENT ON COLUMN moderation_flags.flag_reason IS 'Primary reason for flag (hate, greed, delusion, etc.)';
COMMENT ON COLUMN moderation_flags.scores IS 'JSON object containing detailed analysis scores';
COMMENT ON COLUMN moderation_flags.status IS 'Current moderation status (pending, approved, rejected, escalated)';
COMMENT ON COLUMN moderation_flags.reviewed_by IS 'Admin who reviewed this flag';
COMMENT ON COLUMN moderation_flags.reviewed_at IS 'When this flag was reviewed';
COMMENT ON TABLE user_status_history IS 'History of user status changes for audit trail';
COMMENT ON COLUMN user_status_history.user_id IS 'User whose status changed';
COMMENT ON COLUMN user_status_history.old_status IS 'Previous status before change';
COMMENT ON COLUMN user_status_history.new_status IS 'New status after change';
COMMENT ON COLUMN user_status_history.reason IS 'Reason for status change';
COMMENT ON COLUMN user_status_history.changed_by IS 'Admin who made the change';
COMMENT ON COLUMN users.status IS 'Current user moderation status (active, suspended, banned)';

View file

@ -1,109 +0,0 @@
-- Create moderation_flags table for AI-powered content moderation
CREATE TABLE IF NOT EXISTS moderation_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID,
comment_id UUID,
flag_reason TEXT NOT NULL,
scores JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'escalated')),
reviewed_by UUID,
reviewed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure at least one of post_id or comment_id is set
CONSTRAINT moderation_flags_content_check CHECK (
(post_id IS NOT NULL) OR (comment_id IS NOT NULL)
)
);
-- Add indexes for performance
CREATE INDEX IF NOT EXISTS idx_moderation_flags_post_id ON moderation_flags(post_id);
CREATE INDEX IF NOT EXISTS idx_moderation_flags_comment_id ON moderation_flags(comment_id);
CREATE INDEX IF NOT EXISTS idx_moderation_flags_status ON moderation_flags(status);
CREATE INDEX IF NOT EXISTS idx_moderation_flags_created_at ON moderation_flags(created_at);
-- Add GIN index for JSONB scores to enable efficient querying
CREATE INDEX IF NOT EXISTS idx_moderation_flags_scores_gin ON moderation_flags USING GIN(scores);
-- Add status column to users table for user moderation (if not exists)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='status') THEN
ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'banned'));
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
END IF;
END $$;
-- Create user_status_history table to track status changes
CREATE TABLE IF NOT EXISTS user_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
old_status TEXT,
new_status TEXT NOT NULL,
reason TEXT,
changed_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add index for user status history queries
CREATE INDEX IF NOT EXISTS idx_user_status_history_user_id ON user_status_history(user_id);
CREATE INDEX IF NOT EXISTS idx_user_status_history_created_at ON user_status_history(created_at);
-- Create trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_moderation_flags_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS moderation_flags_updated_at ON moderation_flags;
CREATE TRIGGER moderation_flags_updated_at
BEFORE UPDATE ON moderation_flags
FOR EACH ROW
EXECUTE FUNCTION update_moderation_flags_updated_at();
-- Create trigger to track user status changes
CREATE OR REPLACE FUNCTION log_user_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO user_status_history (user_id, old_status, new_status, reason, changed_by)
VALUES (NEW.id, OLD.status, NEW.status, 'Status changed by system', NEW.id);
END IF;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS user_status_change_log ON users;
CREATE TRIGGER user_status_change_log
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION log_user_status_change();
-- Grant permissions to postgres user (Directus will connect as postgres)
GRANT SELECT, INSERT, UPDATE, DELETE ON moderation_flags TO postgres;
GRANT SELECT, INSERT, UPDATE, DELETE ON user_status_history TO postgres;
GRANT SELECT, UPDATE ON users TO postgres;
-- Add comments for Directus UI
COMMENT ON TABLE moderation_flags IS 'AI-powered content moderation flags for posts and comments';
COMMENT ON COLUMN moderation_flags.id IS 'Unique identifier for the moderation flag';
COMMENT ON COLUMN moderation_flags.post_id IS 'Reference to the post being moderated';
COMMENT ON COLUMN moderation_flags.comment_id IS 'Reference to the comment being moderated';
COMMENT ON COLUMN moderation_flags.flag_reason IS 'Primary reason for flag (hate, greed, delusion, etc.)';
COMMENT ON COLUMN moderation_flags.scores IS 'JSON object containing detailed analysis scores';
COMMENT ON COLUMN moderation_flags.status IS 'Current moderation status (pending, approved, rejected, escalated)';
COMMENT ON COLUMN moderation_flags.reviewed_by IS 'Admin who reviewed this flag';
COMMENT ON COLUMN moderation_flags.reviewed_at IS 'When this flag was reviewed';
COMMENT ON TABLE user_status_history IS 'History of user status changes for audit trail';
COMMENT ON COLUMN user_status_history.user_id IS 'User whose status changed';
COMMENT ON COLUMN user_status_history.old_status IS 'Previous status before change';
COMMENT ON COLUMN user_status_history.new_status IS 'New status after change';
COMMENT ON COLUMN user_status_history.reason IS 'Reason for status change';
COMMENT ON COLUMN user_status_history.changed_by IS 'Admin who made the change';
COMMENT ON COLUMN users.status IS 'Current user moderation status (active, suspended, banned)';

View file

@ -1,192 +0,0 @@
-- User Appeal System Migration
-- Creates tables for tracking user violations, appeals, and ban management
-- User Violations Table
CREATE TABLE IF NOT EXISTS user_violations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
moderation_flag_id UUID NOT NULL REFERENCES moderation_flags(id) ON DELETE CASCADE,
violation_type VARCHAR(20) NOT NULL CHECK (violation_type IN ('hard_violation', 'soft_violation')),
violation_reason TEXT NOT NULL,
severity_score DECIMAL(3,2) NOT NULL CHECK (severity_score >= 0.0 AND severity_score <= 1.0),
is_appealable BOOLEAN NOT NULL DEFAULT false,
appeal_deadline TIMESTAMP WITH TIME ZONE,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'appealed', 'upheld', 'overturned', 'expired')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- User Appeals Table
CREATE TABLE IF NOT EXISTS user_appeals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_violation_id UUID NOT NULL REFERENCES user_violations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
appeal_reason TEXT NOT NULL,
appeal_context TEXT,
evidence_urls JSONB DEFAULT '[]',
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewing', 'approved', 'rejected', 'withdrawn')),
reviewed_by UUID REFERENCES directus_users(id),
review_decision TEXT,
reviewed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- User Violation History (for tracking patterns)
CREATE TABLE IF NOT EXISTS user_violation_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
violation_date DATE NOT NULL,
total_violations INTEGER NOT NULL DEFAULT 0,
hard_violations INTEGER NOT NULL DEFAULT 0,
soft_violations INTEGER NOT NULL DEFAULT 0,
appeals_filed INTEGER NOT NULL DEFAULT 0,
appeals_upheld INTEGER NOT NULL DEFAULT 0,
appeals_overturned INTEGER NOT NULL DEFAULT 0,
current_status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (current_status IN ('active', 'suspended', 'banned')),
ban_expiry TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, violation_date)
);
-- Appeal Guidelines (configurable rules)
CREATE TABLE IF NOT EXISTS appeal_guidelines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
violation_type VARCHAR(20) NOT NULL,
max_appeals_per_month INTEGER NOT NULL DEFAULT 3,
appeal_window_hours INTEGER NOT NULL DEFAULT 72,
auto_ban_threshold INTEGER NOT NULL DEFAULT 5,
hard_violation_ban_threshold INTEGER NOT NULL DEFAULT 2,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert default appeal guidelines
INSERT INTO appeal_guidelines (violation_type, max_appeals_per_month, appeal_window_hours, auto_ban_threshold, hard_violation_ban_threshold)
VALUES
('hard_violation', 0, 0, 5, 2),
('soft_violation', 3, 72, 8, 3)
ON CONFLICT DO NOTHING;
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_user_violations_user_id ON user_violations(user_id);
CREATE INDEX IF NOT EXISTS idx_user_violations_status ON user_violations(status);
CREATE INDEX IF NOT EXISTS idx_user_violations_created_at ON user_violations(created_at);
CREATE INDEX IF NOT EXISTS idx_user_appeals_user_id ON user_appeals(user_id);
CREATE INDEX IF NOT EXISTS idx_user_appeals_status ON user_appeals(status);
CREATE INDEX IF NOT EXISTS idx_user_violation_history_user_id ON user_violation_history(user_id);
CREATE INDEX IF NOT EXISTS idx_user_violation_history_date ON user_violation_history(violation_date);
-- Functions to automatically detect violation type and create violations
CREATE OR REPLACE FUNCTION create_user_violation(
p_user_id UUID,
p_moderation_flag_id UUID,
p_flag_reason TEXT,
p_scores JSONB
) RETURNS UUID AS $$
DECLARE
v_violation_id UUID;
v_violation_type TEXT;
v_severity DECIMAL;
v_is_appealable BOOLEAN;
v_appeal_deadline TIMESTAMP WITH TIME ZONE;
BEGIN
-- Determine violation type based on scores and reason
CASE
WHEN p_flag_reason IN ('hate') AND (p_scores->>'hate')::DECIMAL > 0.8 THEN
BEGIN
v_violation_type := 'hard_violation';
v_severity := (p_scores->>'hate')::DECIMAL;
v_is_appealable := false;
v_appeal_deadline := NULL;
END;
WHEN p_flag_reason IN ('hate', 'violence', 'sexual') AND (p_scores->>'hate')::DECIMAL > 0.6 THEN
BEGIN
v_violation_type := 'hard_violation';
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
v_is_appealable := false;
v_appeal_deadline := NULL;
END;
ELSE
BEGIN
v_violation_type := 'soft_violation';
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
v_is_appealable := true;
v_appeal_deadline := NOW() + (SELECT appeal_window_hours FROM appeal_guidelines WHERE violation_type = 'soft_violation' AND is_active = true LIMIT 1) * INTERVAL '1 hour';
END;
END CASE;
-- Create the violation record
INSERT INTO user_violations (user_id, moderation_flag_id, violation_type, violation_reason, severity_score, is_appealable, appeal_deadline)
VALUES (p_user_id, p_moderation_flag_id, v_violation_type, p_flag_reason, v_severity, v_is_appealable, v_appeal_deadline)
RETURNING id INTO v_violation_id;
-- Update violation history
INSERT INTO user_violation_history (user_id, violation_date, total_violations, hard_violations, soft_violations)
VALUES (p_user_id, CURRENT_DATE, 1,
CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END)
ON CONFLICT (user_id, violation_date)
DO UPDATE SET
total_violations = user_violation_history.total_violations + 1,
hard_violations = user_violation_history.hard_violations + CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
soft_violations = user_violation_history.soft_violations + CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END,
updated_at = NOW();
-- Check for auto-ban conditions
PERFORM check_user_ban_status(p_user_id);
RETURN v_violation_id;
END;
$$ LANGUAGE plpgsql;
-- Function to check if user should be banned
CREATE OR REPLACE FUNCTION check_user_ban_status(p_user_id UUID) RETURNS BOOLEAN AS $$
DECLARE
v_hard_count INTEGER;
v_total_count INTEGER;
v_ban_threshold INTEGER;
v_hard_ban_threshold INTEGER;
BEGIN
-- Get counts from last 30 days
SELECT COUNT(*), SUM(CASE WHEN violation_type = 'hard_violation' THEN 1 ELSE 0 END)
INTO v_total_count, v_hard_count
FROM user_violations
WHERE user_id = p_user_id
AND created_at >= NOW() - INTERVAL '30 days';
-- Get thresholds
SELECT auto_ban_threshold, hard_violation_ban_threshold
INTO v_ban_threshold, v_hard_ban_threshold
FROM appeal_guidelines
WHERE is_active = true
LIMIT 1;
-- Check ban conditions
IF v_hard_count >= v_hard_ban_threshold OR v_total_count >= v_ban_threshold THEN
-- Ban the user
UPDATE users
SET status = 'banned', updated_at = NOW()
WHERE id = p_user_id;
-- Update violation history
UPDATE user_violation_history
SET current_status = 'banned', updated_at = NOW()
WHERE user_id = p_user_id AND violation_date = CURRENT_DATE;
RETURN true;
END IF;
RETURN false;
END;
$$ LANGUAGE plpgsql;
-- Add to Directus collections
INSERT INTO directus_collections (collection, icon, note, hidden, singleton) VALUES
('user_violations', 'warning', 'User violations and moderation records', false, false),
('user_appeals', 'gavel', 'User appeals for moderation decisions', false, false),
('user_violation_history', 'history', 'Daily violation history for users', false, false),
('appeal_guidelines', 'settings', 'Configurable appeal system guidelines', false, false)
ON CONFLICT (collection) DO NOTHING;

View file

@ -1,208 +0,0 @@
-- Updated User Appeal System Migration
-- Changes: More nuanced violation handling - content deletion + account marking instead of immediate bans
-- Update user_violations table to include content deletion tracking
ALTER TABLE user_violations ADD COLUMN IF NOT EXISTS content_deleted BOOLEAN DEFAULT false;
ALTER TABLE user_violations ADD COLUMN IF NOT EXISTS content_deletion_reason TEXT;
ALTER TABLE user_violations ADD COLUMN IF NOT EXISTS account_status_change VARCHAR(20) DEFAULT 'none';
-- Update user_violation_history table
ALTER TABLE user_violation_history ADD COLUMN IF NOT EXISTS content_deletions INTEGER DEFAULT 0;
ALTER TABLE user_violation_history ADD COLUMN IF NOT EXISTS account_warnings INTEGER DEFAULT 0;
ALTER TABLE user_violation_history ADD COLUMN IF NOT EXISTS account_suspensions INTEGER DEFAULT 0;
-- Update appeal guidelines to reflect new approach
UPDATE appeal_guidelines SET
auto_ban_threshold = 999, -- Disable auto-ban
hard_violation_ban_threshold = 999, -- Disable auto-ban
is_active = true
WHERE violation_type IN ('hard_violation', 'soft_violation');
-- Add new status options for user accounts
ALTER TABLE users ADD CONSTRAINT IF NOT EXISTS users_status_check
CHECK (status IN ('active', 'warning', 'suspended', 'banned', 'under_review'));
-- Update the ban checking function to use content deletion instead
CREATE OR REPLACE FUNCTION check_user_violation_status(p_user_id UUID) RETURNS TEXT AS $$
DECLARE
v_hard_count INTEGER;
v_total_count INTEGER;
v_soft_count INTEGER;
v_content_deletions INTEGER;
v_new_status TEXT := 'active';
BEGIN
-- Get counts from last 30 days
SELECT
COUNT(CASE WHEN violation_type = 'hard_violation' THEN 1 END),
COUNT(*),
COUNT(CASE WHEN violation_type = 'soft_violation' THEN 1 END),
COUNT(CASE WHEN content_deleted = true THEN 1 END)
INTO v_hard_count, v_total_count, v_soft_count, v_content_deletions
FROM user_violations
WHERE user_id = p_user_id
AND created_at >= NOW() - INTERVAL '30 days';
-- Determine account status based on violation patterns
IF v_hard_count >= 3 THEN
v_new_status := 'banned';
ELSIF v_total_count >= 8 THEN
v_new_status := 'banned';
ELSIF v_hard_count >= 2 THEN
v_new_status := 'suspended';
ELSIF v_total_count >= 5 THEN
v_new_status := 'suspended';
ELSIF v_hard_count >= 1 OR v_total_count >= 3 THEN
v_new_status := 'warning';
END IF;
-- Update user status if it needs to change
IF v_new_status != 'active' THEN
UPDATE users
SET status = v_new_status, updated_at = NOW()
WHERE id = p_user_id AND status != v_new_status;
-- Update violation history
UPDATE user_violation_history
SET
current_status = v_new_status,
content_deletions = v_content_deletions,
account_warnings = CASE WHEN v_new_status = 'warning' THEN account_warnings + 1 ELSE account_warnings END,
account_suspensions = CASE WHEN v_new_status = 'suspended' THEN account_suspensions + 1 ELSE account_suspensions END,
updated_at = NOW()
WHERE user_id = p_user_id AND violation_date = CURRENT_DATE;
END IF;
RETURN v_new_status;
END;
$$ LANGUAGE plpgsql;
-- Create function to handle content deletion for violations
CREATE OR REPLACE FUNCTION handle_violation_content_deletion(
p_user_id UUID,
p_moderation_flag_id UUID,
p_violation_type TEXT
) RETURNS BOOLEAN AS $$
DECLARE
v_post_id UUID;
v_comment_id UUID;
v_content_deleted BOOLEAN := false;
BEGIN
-- Get the content ID from the moderation flag
SELECT post_id, comment_id
INTO v_post_id, v_comment_id
FROM moderation_flags
WHERE id = p_moderation_flag_id;
-- Delete or hide the content based on violation type
IF p_violation_type = 'hard_violation' THEN
-- Hard violations: delete content immediately
IF v_post_id IS NOT NULL THEN
UPDATE posts SET status = 'deleted', updated_at = NOW() WHERE id = v_post_id;
v_content_deleted := true;
END IF;
IF v_comment_id IS NOT NULL THEN
UPDATE comments SET status = 'deleted', updated_at = NOW() WHERE id = v_comment_id;
v_content_deleted := true;
END IF;
-- Mark the violation record
UPDATE user_violations
SET content_deleted = true,
content_deletion_reason = 'Hard violation - immediate deletion',
account_status_change = CASE
WHEN (SELECT COUNT(*) FROM user_violations WHERE user_id = p_user_id AND violation_type = 'hard_violation') >= 2 THEN 'suspended'
WHEN (SELECT COUNT(*) FROM user_violations WHERE user_id = p_user_id AND violation_type = 'hard_violation') >= 1 THEN 'warning'
ELSE 'none'
END,
updated_at = NOW()
WHERE moderation_flag_id = p_moderation_flag_id;
ELSIF p_violation_type = 'soft_violation' THEN
-- Soft violations: hide content pending appeal
IF v_post_id IS NOT NULL THEN
UPDATE posts SET status = 'pending_moderation', updated_at = NOW() WHERE id = v_post_id;
END IF;
IF v_comment_id IS NOT NULL THEN
UPDATE comments SET status = 'pending_moderation', updated_at = NOW() WHERE id = v_comment_id;
END IF;
-- Mark the violation record
UPDATE user_violations
SET content_deleted = false,
content_deletion_reason = 'Soft violation - pending moderation',
account_status_change = 'none',
updated_at = NOW()
WHERE moderation_flag_id = p_moderation_flag_id;
END IF;
-- Check if user status needs updating
PERFORM check_user_violation_status(p_user_id);
RETURN v_content_deleted;
END;
$$ LANGUAGE plpgsql;
-- Update the create_user_violation function to use the new content handling
CREATE OR REPLACE FUNCTION create_user_violation(
p_user_id UUID,
p_moderation_flag_id UUID,
p_flag_reason TEXT,
p_scores JSONB
) RETURNS UUID AS $$
DECLARE
v_violation_id UUID;
v_violation_type TEXT;
v_severity DECIMAL;
v_is_appealable BOOLEAN;
v_appeal_deadline TIMESTAMP WITH TIME ZONE;
BEGIN
-- Determine violation type based on scores and reason
CASE
WHEN p_flag_reason IN ('hate') AND (p_scores->>'hate')::DECIMAL > 0.8 THEN
BEGIN
v_violation_type := 'hard_violation';
v_severity := (p_scores->>'hate')::DECIMAL;
v_is_appealable := false;
v_appeal_deadline := NULL;
END;
WHEN p_flag_reason IN ('hate', 'violence', 'sexual') AND (p_scores->>'hate')::DECIMAL > 0.6 THEN
BEGIN
v_violation_type := 'hard_violation';
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
v_is_appealable := false;
v_appeal_deadline := NULL;
END;
ELSE
BEGIN
v_violation_type := 'soft_violation';
v_severity := GREATEST((p_scores->>'hate')::DECIMAL, (p_scores->>'greed')::DECIMAL, (p_scores->>'delusion')::DECIMAL);
v_is_appealable := true;
v_appeal_deadline := NOW() + (SELECT appeal_window_hours FROM appeal_guidelines WHERE violation_type = 'soft_violation' AND is_active = true LIMIT 1) * INTERVAL '1 hour';
END;
END CASE;
-- Create the violation record
INSERT INTO user_violations (user_id, moderation_flag_id, violation_type, violation_reason, severity_score, is_appealable, appeal_deadline)
VALUES (p_user_id, p_moderation_flag_id, v_violation_type, p_flag_reason, v_severity, v_is_appealable, v_appeal_deadline)
RETURNING id INTO v_violation_id;
-- Update violation history
INSERT INTO user_violation_history (user_id, violation_date, total_violations, hard_violations, soft_violations)
VALUES (p_user_id, CURRENT_DATE, 1,
CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END)
ON CONFLICT (user_id, violation_date)
DO UPDATE SET
total_violations = user_violation_history.total_violations + 1,
hard_violations = user_violation_history.hard_violations + CASE WHEN v_violation_type = 'hard_violation' THEN 1 ELSE 0 END,
soft_violations = user_violation_history.soft_violations + CASE WHEN v_violation_type = 'soft_violation' THEN 1 ELSE 0 END,
updated_at = NOW();
-- Handle content deletion based on violation type
PERFORM handle_violation_content_deletion(p_user_id, p_moderation_flag_id, v_violation_type);
RETURN v_violation_id;
END;
$$ LANGUAGE plpgsql;

View file

@ -1,11 +0,0 @@
-- Add email preference columns to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_newsletter BOOLEAN DEFAULT false;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_contact BOOLEAN DEFAULT false;
-- Add indexes for performance if needed
CREATE INDEX IF NOT EXISTS idx_users_email_newsletter ON users(email_newsletter);
CREATE INDEX IF NOT EXISTS idx_users_email_contact ON users(email_contact);
-- Add comment for documentation
COMMENT ON COLUMN users.email_newsletter IS 'User has opted in to receive newsletter emails';
COMMENT ON COLUMN users.email_contact IS 'User has opted in to receive contact/transactional emails';

View file

@ -1,93 +0,0 @@
-- Admin Panel Support Tables
-- Algorithm configuration (key-value store for feed/moderation tuning)
CREATE TABLE IF NOT EXISTS public.algorithm_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed default algorithm config values
INSERT INTO public.algorithm_config (key, value, description) VALUES
('feed_recency_weight', '0.4', 'Weight for post recency in feed ranking'),
('feed_engagement_weight', '0.3', 'Weight for engagement metrics (likes, comments)'),
('feed_harmony_weight', '0.2', 'Weight for author harmony/trust score'),
('feed_diversity_weight', '0.1', 'Weight for content diversity in feed'),
('moderation_auto_flag_threshold', '0.7', 'AI score threshold for auto-flagging content'),
('moderation_auto_remove_threshold', '0.95', 'AI score threshold for automatic content removal'),
('moderation_greed_keyword_threshold', '0.7', 'Keyword-based spam/greed detection threshold'),
('feed_max_posts_per_author', '3', 'Max posts from same author in a single feed page'),
('feed_boost_mutual_follow', '1.5', 'Multiplier boost for posts from mutual follows'),
('feed_beacon_boost', '1.2', 'Multiplier boost for beacon posts in nearby feeds')
ON CONFLICT (key) DO NOTHING;
-- Audit log for admin actions
CREATE TABLE IF NOT EXISTS public.audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
actor_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL, -- 'user', 'post', 'comment', 'appeal', 'report', 'config'
target_id UUID,
details TEXT, -- JSON string with action-specific details
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON public.audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON public.audit_log(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON public.audit_log(action);
-- Ensure profiles.role column exists (may already exist from prior migrations)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'role'
) THEN
ALTER TABLE public.profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
END IF;
END $$;
-- Ensure profiles.is_verified column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_verified'
) THEN
ALTER TABLE public.profiles ADD COLUMN is_verified BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Ensure profiles.is_private column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'is_private'
) THEN
ALTER TABLE public.profiles ADD COLUMN is_private BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Ensure users.status column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'status'
) THEN
ALTER TABLE public.users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
END IF;
END $$;
-- Ensure users.last_login column exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_login'
) THEN
ALTER TABLE public.users ADD COLUMN last_login TIMESTAMPTZ;
END IF;
END $$;

View file

@ -1,48 +0,0 @@
-- Official account AI posting configurations
CREATE TABLE IF NOT EXISTS official_account_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
account_type TEXT NOT NULL DEFAULT 'general', -- 'general', 'news', 'community', etc.
enabled BOOLEAN NOT NULL DEFAULT false,
-- AI config
model_id TEXT NOT NULL DEFAULT 'google/gemini-2.0-flash-001',
system_prompt TEXT NOT NULL DEFAULT '',
temperature DOUBLE PRECISION NOT NULL DEFAULT 0.7,
max_tokens INTEGER NOT NULL DEFAULT 500,
-- Posting config
post_interval_minutes INTEGER NOT NULL DEFAULT 60,
max_posts_per_day INTEGER NOT NULL DEFAULT 24,
posts_today INTEGER NOT NULL DEFAULT 0,
posts_today_reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_posted_at TIMESTAMPTZ,
-- News-specific config (only for account_type = 'news')
news_sources JSONB NOT NULL DEFAULT '[]'::jsonb, -- array of {name, rss_url, enabled}
last_fetched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_profile_config UNIQUE (profile_id)
);
CREATE INDEX IF NOT EXISTS idx_oac_profile ON official_account_configs(profile_id);
CREATE INDEX IF NOT EXISTS idx_oac_enabled ON official_account_configs(enabled) WHERE enabled = true;
-- Track which news articles have already been posted to avoid duplicates
CREATE TABLE IF NOT EXISTS official_account_posted_articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_id UUID NOT NULL REFERENCES official_account_configs(id) ON DELETE CASCADE,
article_url TEXT NOT NULL,
article_title TEXT NOT NULL DEFAULT '',
source_name TEXT NOT NULL DEFAULT '',
posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
CONSTRAINT unique_article_per_config UNIQUE (config_id, article_url)
);
CREATE INDEX IF NOT EXISTS idx_oapa_config ON official_account_posted_articles(config_id);
CREATE INDEX IF NOT EXISTS idx_oapa_url ON official_account_posted_articles(article_url);

View file

@ -1,10 +0,0 @@
-- Add link preview columns to posts table
ALTER TABLE public.posts
ADD COLUMN IF NOT EXISTS link_preview_url TEXT,
ADD COLUMN IF NOT EXISTS link_preview_title TEXT,
ADD COLUMN IF NOT EXISTS link_preview_description TEXT,
ADD COLUMN IF NOT EXISTS link_preview_image_url TEXT,
ADD COLUMN IF NOT EXISTS link_preview_site_name TEXT;
-- Index for quick lookups when enriching posts
CREATE INDEX IF NOT EXISTS idx_posts_link_preview ON public.posts (id) WHERE link_preview_url IS NOT NULL;

View file

@ -1,53 +0,0 @@
-- Safe domains table: approved domains for link previews and external link warnings
CREATE TABLE IF NOT EXISTS safe_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain TEXT NOT NULL UNIQUE,
category TEXT NOT NULL DEFAULT 'general', -- general, news, social, government, education, etc.
is_approved BOOLEAN NOT NULL DEFAULT true, -- true = safe, false = explicitly blocked
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_safe_domains_domain ON safe_domains (domain);
CREATE INDEX IF NOT EXISTS idx_safe_domains_approved ON safe_domains (is_approved);
-- Seed with common safe domains
INSERT INTO safe_domains (domain, category, is_approved, notes) VALUES
-- News
('npr.org', 'news', true, 'National Public Radio'),
('apnews.com', 'news', true, 'Associated Press'),
('bringmethenews.com', 'news', true, 'Bring Me The News - Minnesota'),
('reuters.com', 'news', true, 'Reuters'),
('bbc.com', 'news', true, 'BBC'),
('bbc.co.uk', 'news', true, 'BBC UK'),
('nytimes.com', 'news', true, 'New York Times'),
('washingtonpost.com', 'news', true, 'Washington Post'),
('theguardian.com', 'news', true, 'The Guardian'),
('startribune.com', 'news', true, 'Star Tribune - Minnesota'),
('mprnews.org', 'news', true, 'MPR News - Minnesota'),
('kstp.com', 'news', true, 'KSTP - Minnesota'),
('kare11.com', 'news', true, 'KARE 11 - Minnesota'),
('fox9.com', 'news', true, 'Fox 9 - Minnesota'),
('wcco.com', 'news', true, 'WCCO - Minnesota'),
-- Social / Tech
('wikipedia.org', 'education', true, 'Wikipedia'),
('github.com', 'tech', true, 'GitHub'),
('youtube.com', 'social', true, 'YouTube'),
('youtu.be', 'social', true, 'YouTube short links'),
('vimeo.com', 'social', true, 'Vimeo'),
('spotify.com', 'social', true, 'Spotify'),
('open.spotify.com', 'social', true, 'Spotify Open'),
('soundcloud.com', 'social', true, 'SoundCloud'),
('twitch.tv', 'social', true, 'Twitch'),
('reddit.com', 'social', true, 'Reddit'),
('imgur.com', 'social', true, 'Imgur'),
-- Government
('gov', 'government', true, 'US Government domains'),
('state.mn.us', 'government', true, 'Minnesota State'),
('minneapolismn.gov', 'government', true, 'City of Minneapolis'),
('stpaul.gov', 'government', true, 'City of St. Paul'),
-- Sojorn
('sojorn.net', 'internal', true, 'Sojorn'),
('gosojorn.com', 'internal', true, 'Sojorn legacy')
ON CONFLICT (domain) DO NOTHING;

View file

@ -1,29 +0,0 @@
-- Article pipeline: track articles from discovery through posting
CREATE TABLE IF NOT EXISTS official_account_articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
config_id UUID NOT NULL REFERENCES official_account_configs(id) ON DELETE CASCADE,
guid TEXT NOT NULL, -- unique article ID (Google News URL or RSS GUID)
title TEXT NOT NULL DEFAULT '',
link TEXT NOT NULL, -- resolved URL (what gets posted)
source_name TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
pub_date TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'discovered', -- discovered | posted | failed | skipped
post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL,
error_message TEXT,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
posted_at TIMESTAMPTZ,
CONSTRAINT unique_article_guid_per_config UNIQUE (config_id, guid)
);
CREATE INDEX IF NOT EXISTS idx_oaa_config_status ON official_account_articles(config_id, status);
CREATE INDEX IF NOT EXISTS idx_oaa_discovered ON official_account_articles(discovered_at DESC);
CREATE INDEX IF NOT EXISTS idx_oaa_guid ON official_account_articles(guid);
-- Migrate existing posted articles into the new table
INSERT INTO official_account_articles (config_id, guid, title, link, source_name, status, post_id, discovered_at, posted_at)
SELECT config_id, article_url, article_title, article_url, source_name, 'posted', post_id, posted_at, posted_at
FROM official_account_posted_articles
ON CONFLICT (config_id, guid) DO NOTHING;

View file

@ -0,0 +1,3 @@
-- Add category column to forum threads for sub-forums functionality
ALTER TABLE group_forum_threads ADD COLUMN IF NOT EXISTS category TEXT;
CREATE INDEX IF NOT EXISTS idx_group_forum_threads_category ON group_forum_threads(group_id, category) WHERE is_deleted = FALSE;

View file

@ -9,9 +9,9 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/patbritton/sojorn-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"github.com/patbritton/sojorn-backend/internal/repository" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"github.com/patbritton/sojorn-backend/internal/services" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -196,15 +196,7 @@ func (h *AccountHandler) ConfirmImmediateDestroy(c *gin.Context) {
log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged") log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged")
// Return a simple HTML goodbye page (this is accessed via browser from email link) c.Redirect(http.StatusFound, h.config.AppBaseURL+"/destroyed")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Account Destroyed</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
.card{max-width:480px;background:#16213e;border-radius:16px;padding:48px;text-align:center;}
h1{color:#dc2626;font-size:24px;}p{line-height:1.6;color:#a0a0b0;}</style></head>
<body><div class="card"><h1>Account Destroyed</h1>
<p>Your account and all associated data have been permanently destroyed. This action cannot be undone.</p>
<p style="margin-top:32px;color:#666;">Goodbye. Sojorn</p></div></body></html>`))
} }
// GetAccountStatus returns the current account lifecycle status // GetAccountStatus returns the current account lifecycle status
@ -268,174 +260,15 @@ func generateDestroyToken() (string, error) {
} }
func (h *AccountHandler) sendDeactivationEmail(toEmail, toName string) error { func (h *AccountHandler) sendDeactivationEmail(toEmail, toName string) error {
subject := "Your Sojorn account has been deactivated" return h.emailService.SendDeactivationEmail(toEmail, toName)
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 1px solid #334155;">
<h1 style="color: #f59e0b; font-size: 22px; margin: 0 0 24px 0;">Account Deactivated</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been deactivated. Your profile is now hidden from other users.</p>
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 14px; margin: 0 0 8px 0; font-weight: bold;">What this means:</p>
<ul style="color: #94a3b8; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Your profile, posts, and connections are hidden but <strong>fully preserved</strong></li>
<li>No one can see your account while it is deactivated</li>
<li>You can reactivate at any time simply by <strong>logging back in</strong></li>
</ul>
</div>
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to reactivate your account and secure it by changing your password.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, toName)
textBody := fmt.Sprintf(`Account Deactivated
Hey %s,
Your Sojorn account has been deactivated. Your profile is now hidden from other users.
What this means:
- Your profile, posts, and connections are hidden but fully preserved
- No one can see your account while it is deactivated
- You can reactivate at any time simply by logging back in
If you did not request this, please log back in immediately to reactivate your account and change your password.
MPLS LLC - Sojorn - Minneapolis, MN`, toName)
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
} }
func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error { func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
subject := "Your Sojorn account is scheduled for deletion" return h.emailService.SendDeletionScheduledEmail(toEmail, toName, deletionDate)
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #ef4444;">
<h1 style="color: #ef4444; font-size: 22px; margin: 0 0 24px 0;">Account Deletion Scheduled</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been scheduled for <strong>permanent deletion on %s</strong>.</p>
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">What happens next:</p>
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Your account is immediately deactivated and hidden</li>
<li>On <strong>%s</strong>, all data will be permanently and irreversibly destroyed</li>
<li>This includes posts, messages, encryption keys, profile, followers, and your handle</li>
</ul>
</div>
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">Changed your mind?</p>
<p style="color: #94a3b8; font-size: 14px; margin: 0; line-height: 1.6;">Simply <strong>log back in</strong> before %s to cancel the deletion and reactivate your account. All your data is still intact during the grace period.</p>
</div>
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to cancel the deletion and secure your account.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, toName, deletionDate, deletionDate, deletionDate)
textBody := fmt.Sprintf(`Account Deletion Scheduled
Hey %s,
Your Sojorn account has been scheduled for permanent deletion on %s.
What happens next:
- Your account is immediately deactivated and hidden
- On %s, all data will be permanently and irreversibly destroyed
- This includes posts, messages, encryption keys, profile, followers, and your handle
Changed your mind?
Simply log back in before %s to cancel the deletion and reactivate your account.
If you did not request this, please log back in immediately to cancel the deletion and change your password.
MPLS LLC - Sojorn - Minneapolis, MN`, toName, deletionDate, deletionDate, deletionDate)
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
} }
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error { func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
subject := "FINAL WARNING: Confirm Permanent Account Destruction"
confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s", confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
h.config.APIBaseURL, token) h.config.APIBaseURL, token)
return h.emailService.SendDestroyConfirmationEmail(toEmail, toName, confirmURL)
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #dc2626;">
<h1 style="color: #dc2626; font-size: 24px; margin: 0 0 24px 0;"> Account Destruction Confirmation</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">You requested <strong>immediate and permanent destruction</strong> of your Sojorn account.</p>
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">THIS ACTION IS IRREVERSIBLE</p>
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>All your posts, comments, and media will be permanently deleted</li>
<li>All your messages and encryption keys will be destroyed</li>
<li>Your profile, followers, and social connections will be erased</li>
<li>Your handle will be released and cannot be reclaimed</li>
<li><strong>There is no recovery. No backup. No undo.</strong></li>
</ul>
</div>
<p style="font-size: 16px; line-height: 1.6;">If you are absolutely certain, click the button below. <strong>Your account will be destroyed the instant you click.</strong></p>
<div style="text-align: center; margin: 32px 0;">
<a href="%s" style="background: #dc2626; color: white; padding: 16px 40px; border-radius: 8px; text-decoration: none; font-weight: bold; font-size: 16px; display: inline-block;">PERMANENTLY DESTROY MY ACCOUNT</a>
</div>
<p style="font-size: 13px; color: #888; line-height: 1.6;">This link expires in 1 hour. If you did not request this, you can safely ignore this email your account will not be affected.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, toName, confirmURL)
textBody := fmt.Sprintf(`FINAL WARNING: Confirm Permanent Account Destruction
Hey %s,
You requested IMMEDIATE AND PERMANENT DESTRUCTION of your Sojorn account.
THIS ACTION IS IRREVERSIBLE:
- All posts, comments, and media permanently deleted
- All messages and encryption keys destroyed
- Profile, followers, and connections erased
- Handle released and cannot be reclaimed
- THERE IS NO RECOVERY. NO BACKUP. NO UNDO.
To confirm, visit: %s
This link expires in 1 hour. If you did not request this, ignore this email.
MPLS LLC - Sojorn - Minneapolis, MN`, toName, confirmURL)
return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
} }

View file

@ -1,10 +1,13 @@
package handlers package handlers
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -15,7 +18,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/services" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -26,8 +29,10 @@ type AdminHandler struct {
appealService *services.AppealService appealService *services.AppealService
emailService *services.EmailService emailService *services.EmailService
openRouterService *services.OpenRouterService openRouterService *services.OpenRouterService
azureOpenAIService *services.AzureOpenAIService
officialAccountsService *services.OfficialAccountsService officialAccountsService *services.OfficialAccountsService
linkPreviewService *services.LinkPreviewService linkPreviewService *services.LinkPreviewService
localAIService *services.LocalAIService
jwtSecret string jwtSecret string
turnstileSecret string turnstileSecret string
s3Client *s3.Client s3Client *s3.Client
@ -37,15 +42,17 @@ type AdminHandler struct {
vidDomain string vidDomain string
} }
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
pool: pool, pool: pool,
moderationService: moderationService, moderationService: moderationService,
appealService: appealService, appealService: appealService,
emailService: emailService, emailService: emailService,
openRouterService: openRouterService, openRouterService: openRouterService,
azureOpenAIService: azureOpenAIService,
officialAccountsService: officialAccountsService, officialAccountsService: officialAccountsService,
linkPreviewService: linkPreviewService, linkPreviewService: linkPreviewService,
localAIService: localAIService,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret, turnstileSecret: turnstileSecret,
s3Client: s3Client, s3Client: s3Client,
@ -57,44 +64,46 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
} }
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// Admin Login (invisible Turnstile verification) // Admin Login (invisible ALTCHA verification)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
type AdminLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
AltchaToken string `json:"altcha_token"`
}
func (h *AdminHandler) AdminLogin(c *gin.Context) { func (h *AdminHandler) AdminLogin(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
var req struct { var req AdminLoginRequest
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token"`
}
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Verify Turnstile token // Verify ALTCHA token
if h.turnstileSecret != "" { altchaService := services.NewAltchaService(h.jwtSecret)
turnstileService := services.NewTurnstileService(h.turnstileSecret)
remoteIP := c.ClientIP() remoteIP := c.ClientIP()
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP) altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Admin login: Turnstile verification failed") log.Error().Err(err).Msg("Admin login: ALTCHA verification failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return return
} }
if !turnstileResp.Success {
log.Warn().Strs("errors", turnstileResp.ErrorCodes).Msg("Admin login: Turnstile validation failed") if !altchaResp.Verified {
c.JSON(http.StatusForbidden, gin.H{"error": "Security verification failed. Please try again."}) errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
log.Warn().Str("email", req.Email).Str("error", errorMsg).Msg("Admin login: ALTCHA validation failed")
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
return return
} }
}
// Look up user // Look up user
var userID uuid.UUID var userID uuid.UUID
var passwordHash, status string var passwordHash, status string
err := h.pool.QueryRow(ctx, err = h.pool.QueryRow(ctx,
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`, `SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
req.Email).Scan(&userID, &passwordHash, &status) req.Email).Scan(&userID, &passwordHash, &status)
if err != nil { if err != nil {
@ -1056,7 +1065,7 @@ func (h *AdminHandler) UpdatePostStatus(c *gin.Context) {
// Send email notification // Send email notification
if h.emailService != nil && authorEmail != "" { if h.emailService != nil && authorEmail != "" {
go func() { go func() {
if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil { if err := h.emailService.SendContentRemovalEmail(authorEmail, displayName, "post", reason, strikeCount); err != nil {
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email") log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
} }
}() }()
@ -1111,7 +1120,7 @@ func (h *AdminHandler) DeletePost(c *gin.Context) {
if h.emailService != nil && authorEmail != "" { if h.emailService != nil && authorEmail != "" {
go func() { go func() {
if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil { if err := h.emailService.SendContentRemovalEmail(authorEmail, displayName, "post", reason, strikeCount); err != nil {
log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email") log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email")
} }
}() }()
@ -2584,6 +2593,55 @@ func (h *AdminHandler) ListOpenRouterModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)}) c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
} }
// ListLocalModels returns models available on the local Ollama instance.
func (h *AdminHandler) ListLocalModels(c *gin.Context) {
ctx := c.Request.Context()
// Query Ollama's /api/tags endpoint for locally available models
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:11434/api/tags", nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
return
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Ollama not reachable", "models": []any{}})
return
}
defer resp.Body.Close()
var result struct {
Models []struct {
Name string `json:"name"`
Model string `json:"model"`
ModifiedAt string `json:"modified_at"`
Size int64 `json:"size"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse Ollama response"})
return
}
type localModel struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
}
models := make([]localModel, 0, len(result.Models))
for _, m := range result.Models {
models = append(models, localModel{
ID: m.Name,
Name: m.Name,
Size: m.Size,
})
}
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
}
func (h *AdminHandler) GetAIModerationConfigs(c *gin.Context) { func (h *AdminHandler) GetAIModerationConfigs(c *gin.Context) {
if h.openRouterService == nil { if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
@ -2611,19 +2669,21 @@ func (h *AdminHandler) SetAIModerationConfig(c *gin.Context) {
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
SystemPrompt string `json:"system_prompt"` SystemPrompt string `json:"system_prompt"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Engines []string `json:"engines"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if req.ModerationType != "text" && req.ModerationType != "image" && req.ModerationType != "video" { allowedTypes := map[string]bool{"text": true, "image": true, "video": true, "group_text": true, "group_image": true, "beacon_text": true, "beacon_image": true}
c.JSON(http.StatusBadRequest, gin.H{"error": "moderation_type must be text, image, or video"}) if !allowedTypes[req.ModerationType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid moderation_type"})
return return
} }
adminID := c.GetString("user_id") adminID := c.GetString("user_id")
err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, adminID) err := h.openRouterService.SetModerationConfig(c.Request.Context(), req.ModerationType, req.ModelID, req.ModelName, req.SystemPrompt, req.Enabled, req.Engines, adminID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save config: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save config: %v", err)})
return return
@ -2639,44 +2699,200 @@ func (h *AdminHandler) SetAIModerationConfig(c *gin.Context) {
} }
func (h *AdminHandler) TestAIModeration(c *gin.Context) { func (h *AdminHandler) TestAIModeration(c *gin.Context) {
if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
return
}
var req struct { var req struct {
ModerationType string `json:"moderation_type" binding:"required"` ModerationType string `json:"moderation_type" binding:"required"`
Content string `json:"content"` Content string `json:"content"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
Engine string `json:"engine"` // "local_ai", "openrouter", "openai" — empty = openrouter
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
engine := req.Engine
if engine == "" {
engine = "openrouter"
}
ctx := c.Request.Context() ctx := c.Request.Context()
// Determine the input text for display
inputDisplay := req.Content
if inputDisplay == "" && req.ImageURL != "" {
inputDisplay = req.ImageURL
}
response := gin.H{
"engine": engine,
"moderation_type": req.ModerationType,
"input": inputDisplay,
}
switch engine {
case "local_ai":
if h.localAIService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Local AI not configured"})
return
}
// Local AI only does text moderation
content := req.Content
if content == "" {
content = req.ImageURL // fallback
}
localResult, err := h.localAIService.ModerateText(ctx, content)
if err != nil {
response["error"] = err.Error()
c.JSON(http.StatusOK, response)
return
}
action := "clean"
flagged := false
if !localResult.Allowed {
action = "flag"
flagged = true
}
explanation := "Local AI (llama-guard) determined this content is safe."
if flagged {
explanation = fmt.Sprintf("Local AI (llama-guard) flagged this content. Categories: %v, Severity: %s", localResult.Categories, localResult.Severity)
}
response["result"] = gin.H{
"action": action,
"flagged": flagged,
"reason": localResult.Reason,
"categories": localResult.Categories,
"explanation": explanation,
"raw_content": fmt.Sprintf("allowed=%v cached=%v categories=%v severity=%s reason=%s", localResult.Allowed, localResult.Cached, localResult.Categories, localResult.Severity, localResult.Reason),
}
case "openai":
if h.moderationService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenAI moderation service not configured"})
return
}
content := req.Content
if content == "" {
content = req.ImageURL
}
mediaURLs := []string{}
if req.ImageURL != "" {
mediaURLs = append(mediaURLs, req.ImageURL)
}
scores, reason, err := h.moderationService.AnalyzeContent(ctx, content, mediaURLs)
if err != nil {
response["error"] = err.Error()
c.JSON(http.StatusOK, response)
return
}
action := "clean"
flagged := false
if reason != "" {
action = "flag"
flagged = true
}
response["result"] = gin.H{
"action": action,
"flagged": flagged,
"reason": reason,
"hate": scores.Hate,
"greed": scores.Greed,
"delusion": scores.Delusion,
"hate_detail": fmt.Sprintf("Hate=%.3f", scores.Hate),
"greed_detail": fmt.Sprintf("Greed=%.3f", scores.Greed),
"delusion_detail": fmt.Sprintf("Delusion=%.3f", scores.Delusion),
"explanation": fmt.Sprintf("OpenAI Three Poisons analysis. Hate=%.3f, Greed=%.3f, Delusion=%.3f. %s", scores.Hate, scores.Greed, scores.Delusion, reason),
"raw_content": fmt.Sprintf("Hate=%.4f Greed=%.4f Delusion=%.4f", scores.Hate, scores.Greed, scores.Delusion),
}
case "google":
if h.moderationService == nil || !h.moderationService.HasGoogleVision() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Google Vision not configured"})
return
}
imageURL := req.ImageURL
if imageURL == "" {
// If they passed text content as a URL
imageURL = req.Content
}
if imageURL == "" {
response["error"] = "Image URL required for Google Vision test"
c.JSON(http.StatusOK, response)
return
}
gResult, err := h.moderationService.AnalyzeImageWithGoogleVision(ctx, imageURL)
if err != nil {
response["error"] = err.Error()
c.JSON(http.StatusOK, response)
return
}
response["result"] = gResult
case "openrouter":
if h.openRouterService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OpenRouter not configured"})
return
}
var result *services.ModerationResult var result *services.ModerationResult
var err error var err error
isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video"
switch req.ModerationType { if isImage && req.ImageURL != "" {
case "text": if req.ModerationType == "video" {
result, err = h.openRouterService.ModerateText(ctx, req.Content)
case "image":
result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL)
case "video":
urls := strings.Split(req.ImageURL, ",") urls := strings.Split(req.ImageURL, ",")
result, err = h.openRouterService.ModerateVideo(ctx, urls) result, err = h.openRouterService.ModerateVideo(ctx, urls)
default: } else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid moderation_type"}) result, err = h.openRouterService.ModerateImage(ctx, req.ImageURL)
return
} }
} else {
// Use type-specific config if available, fall back to generic text
result, err = h.openRouterService.ModerateWithType(ctx, req.ModerationType, req.Content, nil)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) // Fall back to generic text moderation
result, err = h.openRouterService.ModerateText(ctx, req.Content)
}
}
if err != nil {
response["error"] = err.Error()
c.JSON(http.StatusOK, response)
return
}
response["result"] = result
case "azure":
if h.azureOpenAIService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Azure OpenAI not configured"})
return
}
var result *services.ModerationResult
var err error
isImage := strings.Contains(req.ModerationType, "image") || req.ModerationType == "video"
if isImage && req.ImageURL != "" {
if req.ModerationType == "video" {
urls := strings.Split(req.ImageURL, ",")
result, err = h.azureOpenAIService.ModerateVideo(ctx, urls)
} else {
result, err = h.azureOpenAIService.ModerateImage(ctx, req.ImageURL)
}
} else {
// Use type-specific config if available, fall back to generic text
result, err = h.azureOpenAIService.ModerateWithType(ctx, req.ModerationType, req.Content, nil)
if err != nil {
// Fall back to generic text moderation
result, err = h.azureOpenAIService.ModerateText(ctx, req.Content)
}
}
if err != nil {
response["error"] = err.Error()
c.JSON(http.StatusOK, response)
return
}
response["result"] = result
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid engine: " + engine})
return return
} }
c.JSON(http.StatusOK, gin.H{"result": result}) c.JSON(http.StatusOK, response)
} }
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@ -3331,39 +3547,22 @@ func (h *AdminHandler) PreviewOfficialPost(c *gin.Context) {
// Fetch available news articles (without posting) // Fetch available news articles (without posting)
func (h *AdminHandler) FetchNewsArticles(c *gin.Context) { func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
items, sourceNames, err := h.officialAccountsService.FetchNewArticles(c.Request.Context(), id) ctx := c.Request.Context()
// Discover new articles first
_, _ = h.officialAccountsService.DiscoverArticles(ctx, id)
// Return the full CachedArticle objects so frontend has IDs for per-article actions
articles, err := h.officialAccountsService.GetArticleQueue(ctx, id, "discovered", 100)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if articles == nil {
type articlePreview struct { articles = []services.CachedArticle{}
Title string `json:"title"`
Link string `json:"link"`
Description string `json:"description"`
Source string `json:"source"`
PubDate string `json:"pub_date"`
} }
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
var previews []articlePreview c.JSON(http.StatusOK, gin.H{"articles": articles, "count": len(articles), "stats": stats})
for i, item := range items {
desc := services.StripHTMLTagsPublic(item.Description)
if len(desc) > 200 {
desc = desc[:200] + "..."
}
previews = append(previews, articlePreview{
Title: item.Title,
Link: item.Link,
Description: desc,
Source: sourceNames[i],
PubDate: item.PubDate,
})
}
if previews == nil {
previews = []articlePreview{}
}
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{"articles": previews, "count": len(previews), "stats": stats})
} }
// Get articles by status for an account (defaults to "posted") // Get articles by status for an account (defaults to "posted")
@ -3389,6 +3588,79 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats}) c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats})
} }
// ── Article Pipeline Management ──────────────────────
// SkipArticle marks a pending article as skipped.
func (h *AdminHandler) SkipArticle(c *gin.Context) {
articleID := c.Param("article_id")
if err := h.officialAccountsService.SkipArticle(c.Request.Context(), articleID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Article skipped"})
}
// DeleteArticle permanently removes an article from the pipeline.
func (h *AdminHandler) DeleteArticle(c *gin.Context) {
articleID := c.Param("article_id")
if err := h.officialAccountsService.DeleteArticle(c.Request.Context(), articleID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Article deleted"})
}
// PostSpecificArticle posts a single pending article by its ID.
func (h *AdminHandler) PostSpecificArticle(c *gin.Context) {
articleID := c.Param("article_id")
article, postID, err := h.officialAccountsService.PostSpecificArticle(c.Request.Context(), articleID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Article posted",
"post_id": postID,
"title": article.Title,
"link": article.Link,
})
}
// CleanupPendingArticles skips or deletes all pending articles older than a date.
func (h *AdminHandler) CleanupPendingArticles(c *gin.Context) {
configID := c.Param("id")
var req struct {
Before string `json:"before" binding:"required"` // ISO date: 2026-02-10
Action string `json:"action" binding:"required"` // "skip" or "delete"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
before, err := time.Parse("2006-01-02", req.Before)
if err != nil {
before, err = time.Parse(time.RFC3339, req.Before)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD or RFC3339."})
return
}
}
affected, err := h.officialAccountsService.CleanupPendingByDate(c.Request.Context(), configID, before, req.Action)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), configID)
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("%d article(s) %sed", affected, req.Action),
"affected": affected,
"stats": stats,
})
}
// ── Safe Domains Management ───────────────────────── // ── Safe Domains Management ─────────────────────────
func (h *AdminHandler) ListSafeDomains(c *gin.Context) { func (h *AdminHandler) ListSafeDomains(c *gin.Context) {
@ -3441,6 +3713,170 @@ func (h *AdminHandler) DeleteSafeDomain(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deleted": true}) c.JSON(http.StatusOK, gin.H{"deleted": true})
} }
// GetAIEngines returns the status of all moderation engines (local AI, OpenRouter, OpenAI).
func (h *AdminHandler) GetAIEngines(c *gin.Context) {
type EngineStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
Configured bool `json:"configured"`
Details any `json:"details,omitempty"`
}
engines := []EngineStatus{}
// 1. Local AI (Ollama via AI Gateway)
localEngine := EngineStatus{
ID: "local_ai",
Name: "Local AI (Ollama)",
Description: "On-server moderation via llama-guard3:1b + content generation via qwen2.5:7b. Free, private, ~2s latency.",
Configured: h.localAIService != nil,
}
if h.localAIService != nil {
health, err := h.localAIService.Healthz(c.Request.Context())
if err != nil {
localEngine.Status = "down"
localEngine.Details = map[string]string{"error": err.Error()}
} else {
localEngine.Status = health.Status
localEngine.Details = health
}
} else {
localEngine.Status = "not_configured"
}
engines = append(engines, localEngine)
// 2. OpenRouter
openRouterEngine := EngineStatus{
ID: "openrouter",
Name: "OpenRouter",
Description: "Cloud AI moderation via configurable models. Supports text, image, and video moderation with customizable system prompts.",
Configured: h.openRouterService != nil,
}
if h.openRouterService != nil {
configs, err := h.openRouterService.GetModerationConfigs(c.Request.Context())
if err != nil {
openRouterEngine.Status = "error"
} else {
enabledCount := 0
for _, cfg := range configs {
if cfg.Enabled {
enabledCount++
}
}
openRouterEngine.Status = "ready"
openRouterEngine.Details = map[string]any{
"total_configs": len(configs),
"enabled_configs": enabledCount,
}
}
} else {
openRouterEngine.Status = "not_configured"
}
engines = append(engines, openRouterEngine)
// 3. OpenAI Moderation (Three Poisons)
openAIEngine := EngineStatus{
ID: "openai",
Name: "OpenAI Moderation (Three Poisons)",
Description: "OpenAI content moderation API with Three Poisons scoring (Hate, Greed, Delusion). Used for posts and comments.",
Configured: h.moderationService != nil,
}
if h.moderationService != nil {
openAIEngine.Status = "ready"
} else {
openAIEngine.Status = "not_configured"
}
engines = append(engines, openAIEngine)
// 4. Google Vision (SafeSearch)
googleEngine := EngineStatus{
ID: "google",
Name: "Google Vision (SafeSearch)",
Description: "Google Cloud Vision SafeSearch detection for images. Returns Adult, Violence, Racy, Spoof, Medical likelihoods.",
Configured: h.moderationService != nil && h.moderationService.HasGoogleVision(),
}
if h.moderationService != nil && h.moderationService.HasGoogleVision() {
googleEngine.Status = "ready"
} else {
googleEngine.Status = "not_configured"
}
engines = append(engines, googleEngine)
// 5. Azure OpenAI
azureEngine := EngineStatus{
ID: "azure",
Name: "Azure OpenAI",
Description: "Microsoft Azure OpenAI service with vision models. Uses your Azure credits. Supports text and image moderation with customizable prompts.",
Configured: h.azureOpenAIService != nil,
}
if h.azureOpenAIService != nil {
azureEngine.Status = "ready"
azureEngine.Details = map[string]any{
"uses_azure_credits": true,
}
} else {
azureEngine.Status = "not_configured"
}
engines = append(engines, azureEngine)
c.JSON(http.StatusOK, gin.H{"engines": engines})
}
func (h *AdminHandler) UploadTestImage(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
return
}
defer file.Close()
// Validate file type
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only image files are allowed"})
return
}
// Validate file size (5MB limit)
if header.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "File too large (max 5MB)"})
return
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("test-%s%s", uuid.New().String()[:8], ext)
// Upload to R2
key := fmt.Sprintf("test-images/%s", filename)
// Read file content
fileData, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
return
}
// Upload to R2
contentType := header.Header.Get("Content-Type")
_, err = h.s3Client.PutObject(c.Request.Context(), &s3.PutObjectInput{
Bucket: aws.String(h.mediaBucket),
Key: aws.String(key),
Body: bytes.NewReader(fileData),
ContentType: aws.String(contentType),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Upload failed"})
return
}
// Return the URL
url := fmt.Sprintf("https://%s/%s", h.imgDomain, key)
c.JSON(http.StatusOK, gin.H{"url": url, "filename": filename})
}
func (h *AdminHandler) CheckURLSafety(c *gin.Context) { func (h *AdminHandler) CheckURLSafety(c *gin.Context) {
urlStr := c.Query("url") urlStr := c.Query("url")
if urlStr == "" { if urlStr == "" {
@ -3450,3 +3886,257 @@ func (h *AdminHandler) CheckURLSafety(c *gin.Context) {
result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr) result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr)
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
// ──────────────────────────────────────────────
// Email Template Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListEmailTemplates(c *gin.Context) {
rows, err := h.pool.Query(c.Request.Context(),
`SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at
FROM email_templates ORDER BY name ASC`)
if err != nil {
log.Error().Err(err).Msg("Failed to list email templates")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"})
return
}
defer rows.Close()
type EmailTemplate struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Subject string `json:"subject"`
Title string `json:"title"`
Header string `json:"header"`
Content string `json:"content"`
ButtonText string `json:"button_text"`
ButtonURL string `json:"button_url"`
ButtonColor string `json:"button_color"`
Footer string `json:"footer"`
TextBody string `json:"text_body"`
Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
var templates []EmailTemplate
for rows.Next() {
var t EmailTemplate
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt); err != nil {
log.Error().Err(err).Msg("Failed to scan email template")
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
func (h *AdminHandler) GetEmailTemplate(c *gin.Context) {
id := c.Param("id")
type EmailTemplate struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Subject string `json:"subject"`
Title string `json:"title"`
Header string `json:"header"`
Content string `json:"content"`
ButtonText string `json:"button_text"`
ButtonURL string `json:"button_url"`
ButtonColor string `json:"button_color"`
Footer string `json:"footer"`
TextBody string `json:"text_body"`
Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
var t EmailTemplate
err := h.pool.QueryRow(c.Request.Context(),
`SELECT id, slug, name, description, subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled, updated_at, created_at
FROM email_templates WHERE id = $1`, id).
Scan(&t.ID, &t.Slug, &t.Name, &t.Description, &t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled, &t.UpdatedAt, &t.CreatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
c.JSON(http.StatusOK, t)
}
func (h *AdminHandler) UpdateEmailTemplate(c *gin.Context) {
id := c.Param("id")
var req struct {
Subject *string `json:"subject"`
Title *string `json:"title"`
Header *string `json:"header"`
Content *string `json:"content"`
ButtonText *string `json:"button_text"`
ButtonURL *string `json:"button_url"`
ButtonColor *string `json:"button_color"`
Footer *string `json:"footer"`
TextBody *string `json:"text_body"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Build dynamic UPDATE
sets := []string{}
args := []interface{}{}
argIdx := 1
if req.Subject != nil {
sets = append(sets, fmt.Sprintf("subject = $%d", argIdx))
args = append(args, *req.Subject)
argIdx++
}
if req.Title != nil {
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
args = append(args, *req.Title)
argIdx++
}
if req.Header != nil {
sets = append(sets, fmt.Sprintf("header = $%d", argIdx))
args = append(args, *req.Header)
argIdx++
}
if req.Content != nil {
sets = append(sets, fmt.Sprintf("content = $%d", argIdx))
args = append(args, *req.Content)
argIdx++
}
if req.ButtonText != nil {
sets = append(sets, fmt.Sprintf("button_text = $%d", argIdx))
args = append(args, *req.ButtonText)
argIdx++
}
if req.ButtonURL != nil {
sets = append(sets, fmt.Sprintf("button_url = $%d", argIdx))
args = append(args, *req.ButtonURL)
argIdx++
}
if req.ButtonColor != nil {
sets = append(sets, fmt.Sprintf("button_color = $%d", argIdx))
args = append(args, *req.ButtonColor)
argIdx++
}
if req.Footer != nil {
sets = append(sets, fmt.Sprintf("footer = $%d", argIdx))
args = append(args, *req.Footer)
argIdx++
}
if req.TextBody != nil {
sets = append(sets, fmt.Sprintf("text_body = $%d", argIdx))
args = append(args, *req.TextBody)
argIdx++
}
if req.Enabled != nil {
sets = append(sets, fmt.Sprintf("enabled = $%d", argIdx))
args = append(args, *req.Enabled)
argIdx++
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}
sets = append(sets, "updated_at = NOW()")
args = append(args, id)
query := fmt.Sprintf("UPDATE email_templates SET %s WHERE id = $%d RETURNING id", strings.Join(sets, ", "), argIdx)
var returnedID string
err := h.pool.QueryRow(c.Request.Context(), query, args...).Scan(&returnedID)
if err != nil {
log.Error().Err(err).Str("template_id", id).Msg("Failed to update email template")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
}
// Log to audit
adminID, _ := c.Get("user_id")
_, _ = h.pool.Exec(c.Request.Context(),
`INSERT INTO audit_log (admin_id, action, target_type, target_id, details) VALUES ($1, 'update_email_template', 'email_template', $2, $3)`,
adminID, id, fmt.Sprintf("Updated email template %s", id))
c.JSON(http.StatusOK, gin.H{"message": "Template updated", "id": returnedID})
}
func (h *AdminHandler) SendTestEmail(c *gin.Context) {
var req struct {
TemplateID string `json:"template_id"`
ToEmail string `json:"to_email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.TemplateID == "" || req.ToEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template_id and to_email are required"})
return
}
var subject, title, header, content, buttonText, buttonURL, buttonColor, footer, textBody string
err := h.pool.QueryRow(c.Request.Context(),
`SELECT subject, title, header, content, button_text, button_url, button_color, footer, text_body
FROM email_templates WHERE id = $1`, req.TemplateID).
Scan(&subject, &title, &header, &content, &buttonText, &buttonURL, &buttonColor, &footer, &textBody)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
// Replace placeholders with sample data for test
replacer := strings.NewReplacer(
"{{name}}", "Test User",
"{{verify_url}}", "https://mp.ls/verified",
"{{reset_url}}", "https://mp.ls/sojorn",
"{{reason}}", "This is a test reason",
"{{duration}}", "7 days",
"{{deletion_date}}", time.Now().AddDate(0, 0, 14).Format("January 2, 2006"),
"{{confirm_url}}", "https://mp.ls/destroyed",
"{{content_type}}", "post",
"{{strike_count}}", "1",
"{{strike_warning}}", "",
)
subject = replacer.Replace(subject)
header = replacer.Replace(header)
content = replacer.Replace(content)
buttonText = replacer.Replace(buttonText)
buttonURL = replacer.Replace(buttonURL)
textBody = replacer.Replace(textBody)
htmlBody := h.emailService.BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, buttonColor)
if err := h.emailService.SendGenericEmail(req.ToEmail, "Test User", subject, htmlBody, textBody); err != nil {
log.Error().Err(err).Msg("Failed to send test email")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test email sent to " + req.ToEmail})
}
func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
altchaService := services.NewAltchaService(h.jwtSecret)
challenge, err := altchaService.GenerateChallenge()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
return
}
c.JSON(http.StatusOK, challenge)
}

Some files were not shown because too many files have changed in this diff Show more